Skip to main content

max / makenotwork

Contact revocation: let fans withdraw email sharing with creators Adds a contact_revocations table so fans can revoke contact sharing from their library page. Revocations are automatically cleared when the fan makes a new purchase with share_contact checked. Creator contacts view and CSV export both respect revocations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-09 00:38 UTC
Commit: adc7f418b6497a6a6c4893759d580b51ce1cbd57
Parent: cba62a0
11 files changed, +157 insertions, -4 deletions
@@ -3453,7 +3453,7 @@ dependencies = [
3453 3453
3454 3454 [[package]]
3455 3455 name = "makenotwork"
3456 - version = "0.1.4"
3456 + version = "0.1.5"
3457 3457 dependencies = [
3458 3458 "ammonia",
3459 3459 "anyhow",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.1.4"
3 + version = "0.1.5"
4 4 edition = "2024"
5 5 license-file = "../../LICENSE"
6 6
@@ -0,0 +1,6 @@
1 + CREATE TABLE contact_revocations (
2 + buyer_id UUID NOT NULL REFERENCES users(id),
3 + seller_id UUID NOT NULL REFERENCES users(id),
4 + revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
5 + PRIMARY KEY (buyer_id, seller_id)
6 + );
@@ -386,10 +386,19 @@ pub struct DbContactRow {
386 386 pub last_purchase_at: DateTime<Utc>,
387 387 }
388 388
389 + /// A creator the fan has actively shared contact info with (no revocation).
390 + #[derive(sqlx::FromRow)]
391 + pub struct SharedCreatorRow {
392 + pub seller_id: UserId,
393 + pub username: String,
394 + pub display_name: Option<String>,
395 + }
396 +
389 397 /// Get unique contacts for a seller: buyers who opted in to share their email.
390 398 ///
391 399 /// Aggregates across all completed transactions where `share_contact = true`,
392 - /// returning one row per buyer with purchase stats.
400 + /// returning one row per buyer with purchase stats. Excludes buyers who have
401 + /// revoked contact sharing.
393 402 pub async fn get_seller_contacts(
394 403 pool: &PgPool,
395 404 seller_id: UserId,
@@ -407,6 +416,10 @@ pub async fn get_seller_contacts(
407 416 WHERE t.seller_id = $1
408 417 AND t.status = 'completed'
409 418 AND t.share_contact = true
419 + AND NOT EXISTS (
420 + SELECT 1 FROM contact_revocations cr
421 + WHERE cr.buyer_id = t.buyer_id AND cr.seller_id = t.seller_id
422 + )
410 423 GROUP BY t.buyer_id, u.username, u.email
411 424 ORDER BY last_purchase_at DESC
412 425 LIMIT 500
@@ -420,6 +433,9 @@ pub async fn get_seller_contacts(
420 433 }
421 434
422 435 /// Get seller transactions for CSV export, with conditional buyer email.
436 + ///
437 + /// Respects contact revocations: if a buyer revoked sharing, their email
438 + /// is hidden even if `share_contact` was true on the transaction.
423 439 pub async fn get_seller_transactions_for_export(
424 440 pool: &PgPool,
425 441 seller_id: UserId,
@@ -432,7 +448,10 @@ pub async fn get_seller_transactions_for_export(
432 448 t.item_title,
433 449 t.amount_cents,
434 450 t.status,
435 - CASE WHEN t.share_contact THEN u.email ELSE NULL END as buyer_email
451 + CASE WHEN t.share_contact AND NOT EXISTS (
452 + SELECT 1 FROM contact_revocations cr
453 + WHERE cr.buyer_id = t.buyer_id AND cr.seller_id = t.seller_id
454 + ) THEN u.email ELSE NULL END as buyer_email
436 455 FROM transactions t
437 456 LEFT JOIN users u ON u.id = t.buyer_id
438 457 WHERE t.seller_id = $1
@@ -446,3 +465,66 @@ pub async fn get_seller_transactions_for_export(
446 465
447 466 Ok(rows)
448 467 }
468 +
469 + /// Record a contact revocation (fan withdraws email sharing from a creator).
470 + ///
471 + /// Idempotent: does nothing if already revoked.
472 + pub async fn revoke_contact_sharing(
473 + pool: &PgPool,
474 + buyer_id: UserId,
475 + seller_id: UserId,
476 + ) -> Result<()> {
477 + sqlx::query(
478 + "INSERT INTO contact_revocations (buyer_id, seller_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
479 + )
480 + .bind(buyer_id)
481 + .bind(seller_id)
482 + .execute(pool)
483 + .await?;
484 +
485 + Ok(())
486 + }
487 +
488 + /// Clear a contact revocation (fan re-shares on a new purchase).
489 + pub async fn clear_contact_revocation(
490 + pool: &PgPool,
491 + buyer_id: UserId,
492 + seller_id: UserId,
493 + ) -> Result<()> {
494 + sqlx::query(
495 + "DELETE FROM contact_revocations WHERE buyer_id = $1 AND seller_id = $2",
496 + )
497 + .bind(buyer_id)
498 + .bind(seller_id)
499 + .execute(pool)
500 + .await?;
501 +
502 + Ok(())
503 + }
504 +
505 + /// Get creators the fan has actively shared contact info with (excluding revoked).
506 + pub async fn get_shared_creators(
507 + pool: &PgPool,
508 + buyer_id: UserId,
509 + ) -> Result<Vec<SharedCreatorRow>> {
510 + let rows = sqlx::query_as::<_, SharedCreatorRow>(
511 + r#"
512 + SELECT DISTINCT t.seller_id, u.username, u.display_name
513 + FROM transactions t
514 + JOIN users u ON u.id = t.seller_id
515 + WHERE t.buyer_id = $1
516 + AND t.status = 'completed'
517 + AND t.share_contact = true
518 + AND NOT EXISTS (
519 + SELECT 1 FROM contact_revocations cr
520 + WHERE cr.buyer_id = t.buyer_id AND cr.seller_id = t.seller_id
521 + )
522 + ORDER BY u.username
523 + "#,
524 + )
525 + .bind(buyer_id)
526 + .fetch_all(pool)
527 + .await?;
528 +
529 + Ok(rows)
530 + }
@@ -202,6 +202,8 @@ pub fn api_routes() -> Router<AppState> {
202 202 // Library routes
203 203 .route("/api/library/add/{item_id}", post(users::add_to_library))
204 204 .route("/api/library/remove/{item_id}", delete(users::remove_from_library))
205 + // Contact sharing revocation
206 + .route("/api/contacts/{seller_id}", delete(users::revoke_contact))
205 207 // Waitlist
206 208 .route("/api/waitlist/apply", post(users::waitlist_apply))
207 209 // Email verification
@@ -336,6 +336,21 @@ pub(super) async fn broadcast_send(
336 336 }
337 337
338 338 // =============================================================================
339 + // Contact Sharing
340 + // =============================================================================
341 +
342 + /// Revoke contact sharing with a specific creator.
343 + #[tracing::instrument(skip_all, name = "users::revoke_contact")]
344 + pub(super) async fn revoke_contact(
345 + State(state): State<AppState>,
346 + AuthUser(user): AuthUser,
347 + Path(seller_id): Path<UserId>,
348 + ) -> Result<impl IntoResponse> {
349 + db::transactions::revoke_contact_sharing(&state.db, user.id, seller_id).await?;
350 + Ok(StatusCode::NO_CONTENT)
351 + }
352 +
353 + // =============================================================================
339 354 // Library API
340 355 // =============================================================================
341 356
@@ -38,11 +38,13 @@ pub(super) async fn library(
38 38 let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?;
39 39 let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?;
40 40 let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
41 + let shared_creators = db::transactions::get_shared_creators(&state.db, user.id).await?;
41 42 Ok(LibraryTemplate {
42 43 csrf_token: get_csrf_token(&session).await,
43 44 session_user: Some(user),
44 45 purchases,
45 46 subscriptions,
47 + shared_creators,
46 48 })
47 49 }
48 50
@@ -154,6 +154,11 @@ pub(super) async fn create_checkout(
154 154 if claimed {
155 155 db::items::increment_sales_count(&state.db, item_uuid).await?;
156 156
157 + // Clear any prior contact revocation if fan is re-sharing
158 + if form.share_contact {
159 + db::transactions::clear_contact_revocation(&state.db, user.id, seller_id).await?;
160 + }
161 +
157 162 // Generate license key if enabled
158 163 if item.enable_license_keys {
159 164 let key_code = helpers::generate_key_code();
@@ -123,6 +123,11 @@ async fn handle_purchase_checkout_completed(
123 123 // Increment denormalized sales_count
124 124 db::items::increment_sales_count(&state.db, item_id).await?;
125 125
126 + // Clear any prior contact revocation if fan is re-sharing
127 + if tx.share_contact {
128 + db::transactions::clear_contact_revocation(&state.db, buyer_id, seller_id).await?;
129 + }
130 +
126 131 // Atomically increment discount code use_count if one was used
127 132 if let Some(dc_id) = discount_code_id {
128 133 let _ = db::discount_codes::try_increment_discount_code_use_count(&state.db, dc_id).await;
@@ -37,6 +37,7 @@ pub struct LibraryTemplate {
37 37 pub session_user: Option<SessionUser>,
38 38 pub purchases: Vec<crate::db::DbPurchaseRow>,
39 39 pub subscriptions: Vec<UserSubscription>,
40 + pub shared_creators: Vec<crate::db::transactions::SharedCreatorRow>,
40 41 }
41 42
42 43 /// Login page.
@@ -144,6 +144,41 @@
144 144 </div>
145 145 {% endif %}
146 146
147 + {% if !shared_creators.is_empty() %}
148 + <h2 style="margin-top: 2.5rem;">Shared Contacts</h2>
149 + <p style="color: var(--text-muted); margin-bottom: 1rem;">You've shared your email with these creators. You can revoke sharing at any time.</p>
150 + <div class="library-container" style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
151 + <table class="data-table" style="min-width: 300px;">
152 + <thead>
153 + <tr>
154 + <th>Creator</th>
155 + <th></th>
156 + </tr>
157 + </thead>
158 + <tbody>
159 + {% for creator in shared_creators %}
160 + <tr id="shared-contact-{{ creator.seller_id }}">
161 + <td>
162 + <a href="/u/{{ creator.username }}" class="library-title-link">
163 + {% if let Some(dn) = creator.display_name %}{{ dn }}{% else %}{{ creator.username }}{% endif %}
164 + </a>
165 + </td>
166 + <td style="text-align: right;">
167 + <button class="btn-small danger"
168 + hx-delete="/api/contacts/{{ creator.seller_id }}"
169 + hx-target="#shared-contact-{{ creator.seller_id }}"
170 + hx-swap="outerHTML"
171 + hx-confirm="Revoke contact sharing with {{ creator.username }}?">
172 + Revoke
173 + </button>
174 + </td>
175 + </tr>
176 + {% endfor %}
177 + </tbody>
178 + </table>
179 + </div>
180 + {% endif %}
181 +
147 182 {% if !subscriptions.is_empty() %}
148 183 <h2 style="margin-top: 2.5rem;">Subscriptions</h2>
149 184 <div class="library-container" style="overflow-x: auto; -webkit-overflow-scrolling: touch;">