max / makenotwork
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;"> |