max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+533 insertions,
-7 deletions
| @@ -1206,6 +1206,14 @@ | |||
| 1206 | 1206 | Issues, | |
| 1207 | 1207 | Announce, | |
| 1208 | 1208 | Marketing, | |
| 1209 | + | // Account notification preferences, each mirroring a users.notify_* column. | |
| 1210 | + | // Releases and Issues above double as these at platform scope; the rest are | |
| 1211 | + | // their own kinds. | |
| 1212 | + | Sale, | |
| 1213 | + | Follower, | |
| 1214 | + | Login, | |
| 1215 | + | Status, | |
| 1216 | + | Tip, | |
| 1209 | 1217 | } | |
| 1210 | 1218 | ||
| 1211 | 1219 | impl_str_enum!(ListKind { | |
| @@ -1216,6 +1224,11 @@ | |||
| 1216 | 1224 | Issues => "issues", | |
| 1217 | 1225 | Announce => "announce", | |
| 1218 | 1226 | Marketing => "marketing", | |
| 1227 | + | Sale => "sale", | |
| 1228 | + | Follower => "follower", | |
| 1229 | + | Login => "login", | |
| 1230 | + | Status => "status", | |
| 1231 | + | Tip => "tip", | |
| 1219 | 1232 | }); | |
| 1220 | 1233 | ||
| 1221 | 1234 | /// Where a subscription stands. |
| @@ -397,6 +397,135 @@ | |||
| 397 | 397 | Ok(required.unwrap_or(false)) | |
| 398 | 398 | } | |
| 399 | 399 | ||
| 400 | + | // ── Account notification preferences ── | |
| 401 | + | // | |
| 402 | + | // Seven bool columns on `users` and seven platform lists describe the same | |
| 403 | + | // thing. Reads still use the columns (each one is a send decision, and moving | |
| 404 | + | // nine of those in the same release as the data would give two suspects when | |
| 405 | + | // something stops arriving), so every write has to keep both sides in step, in | |
| 406 | + | // both directions: | |
| 407 | + | // | |
| 408 | + | // settings screen / unsubscribe email link -> column, then subscription | |
| 409 | + | // preferences page -> subscription, then column | |
| 410 | + | // | |
| 411 | + | // The second direction is what makes the preferences page truthful. Without it | |
| 412 | + | // a user could unsubscribe from "Sales" there and keep receiving sale | |
| 413 | + | // notifications, because the send still reads the column. A page that says it | |
| 414 | + | // changed something and did not is worse than one that never offered. | |
| 415 | + | ||
| 416 | + | /// Platform lists that mirror a `users.notify_*` column, paired with it. | |
| 417 | + | /// | |
| 418 | + | /// The column name is the one `disable_notification` already accepts, so the | |
| 419 | + | /// two agree by construction rather than by a second list somebody has to | |
| 420 | + | /// remember to update. | |
| 421 | + | pub const NOTIFICATION_LISTS: &[(&str, &str)] = &[ | |
| 422 | + | ("sale", "notify_sale"), | |
| 423 | + | ("follower", "notify_follower"), | |
| 424 | + | ("releases", "notify_release"), | |
| 425 | + | ("issues", "notify_issues"), | |
| 426 | + | ("status", "notify_status"), | |
| 427 | + | ("tip", "notify_tip"), | |
| 428 | + | ("login", "login_notification_enabled"), | |
| 429 | + | ]; | |
| 430 | + | ||
| 431 | + | /// The `users` column a platform list mirrors, if it mirrors one. | |
| 432 | + | pub fn notification_column_for_kind(kind: &str) -> Option<&'static str> { | |
| 433 | + | NOTIFICATION_LISTS | |
| 434 | + | .iter() | |
| 435 | + | .find(|(k, _)| *k == kind) | |
| 436 | + | .map(|(_, col)| *col) | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | /// Point a user's notification subscription at `enabled`, appending the consent | |
| 440 | + | /// event that goes with it. Called after the column is written. | |
| 441 | + | #[tracing::instrument(skip_all)] | |
| 442 | + | pub async fn sync_notification_subscription( | |
| 443 | + | pool: &PgPool, | |
| 444 | + | user_id: UserId, | |
| 445 | + | kind: &str, | |
| 446 | + | enabled: bool, | |
| 447 | + | ) -> Result<()> { | |
| 448 | + | let Some(list_id) = sqlx::query_scalar::<_, ListId>( | |
| 449 | + | "SELECT id FROM lists WHERE scope = 'platform' AND kind = $1", | |
| 450 | + | ) | |
| 451 | + | .bind(kind) | |
| 452 | + | .fetch_optional(pool) | |
| 453 | + | .await? | |
| 454 | + | else { | |
| 455 | + | return Ok(()); | |
| 456 | + | }; | |
| 457 | + | ||
| 458 | + | if enabled { | |
| 459 | + | subscribe( | |
| 460 | + | pool, | |
| 461 | + | list_id, | |
| 462 | + | &Subscriber::User(user_id), | |
| 463 | + | SubscriptionState::Confirmed, | |
| 464 | + | SubscriptionSource::Admin, | |
| 465 | + | ConsentEvent::OptIn, | |
| 466 | + | Some("Enabled from account notification settings."), | |
| 467 | + | ) | |
| 468 | + | .await?; | |
| 469 | + | return Ok(()); | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | let existing = sqlx::query_scalar::<_, ListSubscriptionId>( | |
| 473 | + | "SELECT id FROM list_subscriptions WHERE list_id = $1 AND user_id = $2", | |
| 474 | + | ) | |
| 475 | + | .bind(list_id) | |
| 476 | + | .bind(user_id) | |
| 477 | + | .fetch_optional(pool) | |
| 478 | + | .await?; | |
| 479 | + | ||
| 480 | + | match existing { | |
| 481 | + | Some(subscription_id) => { | |
| 482 | + | unsubscribe(pool, subscription_id, ConsentEvent::OptOut).await?; | |
| 483 | + | } | |
| 484 | + | None => { | |
| 485 | + | // No row yet (an account created since the backfill). Record the | |
| 486 | + | // "no" rather than leaving it absent, so it reads as a choice | |
| 487 | + | // rather than as never having been asked. | |
| 488 | + | subscribe( | |
| 489 | + | pool, | |
| 490 | + | list_id, | |
| 491 | + | &Subscriber::User(user_id), | |
| 492 | + | SubscriptionState::Unsubscribed, | |
| 493 | + | SubscriptionSource::Admin, | |
| 494 | + | ConsentEvent::OptOut, | |
| 495 | + | Some("Disabled from account notification settings."), | |
| 496 | + | ) | |
| 497 | + | .await?; | |
| 498 | + | } | |
| 499 | + | } | |
| 500 | + | Ok(()) | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | /// The `users` column behind a subscription, if it has one. | |
| 504 | + | /// | |
| 505 | + | /// Used by the preferences page: a toggle there has to reach the column, or the | |
| 506 | + | /// send that reads the column will ignore it. | |
| 507 | + | #[tracing::instrument(skip_all)] | |
| 508 | + | pub async fn notification_column_for_subscription( | |
| 509 | + | pool: &PgPool, | |
| 510 | + | subscription_id: ListSubscriptionId, | |
| 511 | + | ) -> Result<Option<(UserId, &'static str)>> { | |
| 512 | + | let row = sqlx::query_as::<_, (Option<UserId>, String, String)>( | |
| 513 | + | "SELECT ls.user_id, l.scope, l.kind FROM list_subscriptions ls \ | |
| 514 | + | JOIN lists l ON l.id = ls.list_id WHERE ls.id = $1", | |
| 515 | + | ) | |
| 516 | + | .bind(subscription_id) | |
| 517 | + | .fetch_optional(pool) | |
| 518 | + | .await?; | |
| 519 | + | ||
| 520 | + | let Some((Some(user_id), scope, kind)) = row else { | |
| 521 | + | return Ok(None); | |
| 522 | + | }; | |
| 523 | + | if scope != "platform" { | |
| 524 | + | return Ok(None); | |
| 525 | + | } | |
| 526 | + | Ok(notification_column_for_kind(&kind).map(|col| (user_id, col))) | |
| 527 | + | } | |
| 528 | + | ||
| 400 | 529 | // ── Mirroring the legacy tables ── | |
| 401 | 530 | // | |
| 402 | 531 | // `mailing_lists` / `mailing_list_subscribers` are still what the product |
| @@ -897,6 +897,20 @@ | |||
| 897 | 897 | .execute(pool) | |
| 898 | 898 | .await?; | |
| 899 | 899 | ||
| 900 | + | // Keep the subscription side in step. Reads still use the columns above, | |
| 901 | + | // so this is the mirror rather than the source, but the preferences page | |
| 902 | + | // and any consent question are answered from it. | |
| 903 | + | for (kind, enabled) in [ | |
| 904 | + | ("sale", notify_sale), | |
| 905 | + | ("follower", notify_follower), | |
| 906 | + | ("releases", notify_release), | |
| 907 | + | ("issues", notify_issues), | |
| 908 | + | ("status", notify_status), | |
| 909 | + | ("login", login_notification_enabled), | |
| 910 | + | ] { | |
| 911 | + | crate::db::lists::sync_notification_subscription(pool, id, kind, enabled).await?; | |
| 912 | + | } | |
| 913 | + | ||
| 900 | 914 | Ok(()) | |
| 901 | 915 | } | |
| 902 | 916 | ||
| @@ -957,6 +971,59 @@ | |||
| 957 | 971 | _ => return Ok(false), | |
| 958 | 972 | }; | |
| 959 | 973 | let result = sqlx::query(sql).bind(user_id).execute(pool).await?; | |
| 974 | + | ||
| 975 | + | // Same mirror, for the single-preference path the email links use. | |
| 976 | + | if let Some(kind) = crate::db::lists::NOTIFICATION_LISTS | |
| 977 | + | .iter() | |
| 978 | + | .find(|(_, col)| *col == preference) | |
| 979 | + | .map(|(kind, _)| *kind) | |
| 980 | + | { | |
| 981 | + | crate::db::lists::sync_notification_subscription(pool, user_id, kind, false).await?; | |
| 982 | + | } | |
| 983 | + | ||
| 984 | + | Ok(result.rows_affected() > 0) | |
| 985 | + | } | |
| 986 | + | ||
| 987 | + | /// Set one notification column, either way. | |
| 988 | + | /// | |
| 989 | + | /// The write-back half of the preferences page: reads still use these columns, | |
| 990 | + | /// so a subscription change has to land here to have any effect. | |
| 991 | + | /// | |
| 992 | + | /// Deliberately does NOT mirror into subscriptions, unlike | |
| 993 | + | /// [`disable_notification`]. This function is called *by* the subscription | |
| 994 | + | /// side, and mirroring back would loop and append a duplicate consent event | |
| 995 | + | /// for a change already recorded. | |
| 996 | + | /// | |
| 997 | + | /// `column` comes from `lists::NOTIFICATION_LISTS`, never from a request, and | |
| 998 | + | /// is matched against a fixed set anyway so it cannot reach the query as text. | |
| 999 | + | #[tracing::instrument(skip_all)] | |
| 1000 | + | pub async fn set_notification( | |
| 1001 | + | pool: &PgPool, | |
| 1002 | + | user_id: UserId, | |
| 1003 | + | column: &str, | |
| 1004 | + | enabled: bool, | |
| 1005 | + | ) -> Result<bool> { | |
| 1006 | + | let sql = match column { | |
| 1007 | + | "notify_sale" => "UPDATE users SET notify_sale = $2, updated_at = NOW() WHERE id = $1", | |
| 1008 | + | "notify_follower" => { | |
| 1009 | + | "UPDATE users SET notify_follower = $2, updated_at = NOW() WHERE id = $1" | |
| 1010 | + | } | |
| 1011 | + | "notify_release" => { | |
| 1012 | + | "UPDATE users SET notify_release = $2, updated_at = NOW() WHERE id = $1" | |
| 1013 | + | } | |
| 1014 | + | "login_notification_enabled" => { | |
| 1015 | + | "UPDATE users SET login_notification_enabled = $2, updated_at = NOW() WHERE id = $1" | |
| 1016 | + | } | |
| 1017 | + | "notify_issues" => "UPDATE users SET notify_issues = $2, updated_at = NOW() WHERE id = $1", | |
| 1018 | + | "notify_tip" => "UPDATE users SET notify_tip = $2, updated_at = NOW() WHERE id = $1", | |
| 1019 | + | "notify_status" => "UPDATE users SET notify_status = $2, updated_at = NOW() WHERE id = $1", | |
| 1020 | + | _ => return Ok(false), | |
| 1021 | + | }; | |
| 1022 | + | let result = sqlx::query(sql) | |
| 1023 | + | .bind(user_id) | |
| 1024 | + | .bind(enabled) | |
| 1025 | + | .execute(pool) | |
| 1026 | + | .await?; | |
| 960 | 1027 | Ok(result.rows_affected() > 0) | |
| 961 | 1028 | } | |
| 962 | 1029 |
| @@ -745,3 +745,149 @@ | |||
| 745 | 745 | "a forged signature opened the page" | |
| 746 | 746 | ); | |
| 747 | 747 | } | |
| 748 | + | ||
| 749 | + | // ── Step 5: notification preferences as subscriptions ── | |
| 750 | + | // | |
| 751 | + | // Reads still use the users.notify_* columns, so what matters here is that | |
| 752 | + | // every write keeps both sides in step. A preferences page that reports a | |
| 753 | + | // change it did not make is worse than one that never offered the toggle. | |
| 754 | + | ||
| 755 | + | async fn notification_subscription( | |
| 756 | + | h: &TestHarness, | |
| 757 | + | user: makenotwork::db::UserId, | |
| 758 | + | kind: ListKind, | |
| 759 | + | ) -> makenotwork::db::ListSubscriptionId { | |
| 760 | + | let list = lists::find_list(&h.db, ListScope::Platform, None, kind) | |
| 761 | + | .await | |
| 762 | + | .unwrap() | |
| 763 | + | .expect("notification list exists"); | |
| 764 | + | sqlx::query_scalar("SELECT id FROM list_subscriptions WHERE list_id = $1 AND user_id = $2") | |
| 765 | + | .bind(list) | |
| 766 | + | .bind(user) | |
| 767 | + | .fetch_one(&h.db) | |
| 768 | + | .await | |
| 769 | + | .expect("backfilled subscription") | |
| 770 | + | } | |
| 771 | + | ||
| 772 | + | async fn column(h: &TestHarness, user: makenotwork::db::UserId, col: &str) -> bool { | |
| 773 | + | sqlx::query_scalar(&format!("SELECT {col} FROM users WHERE id = $1")) | |
| 774 | + | .bind(user) | |
| 775 | + | .fetch_one(&h.db) | |
| 776 | + | .await | |
| 777 | + | .unwrap() | |
| 778 | + | } | |
| 779 | + | ||
| 780 | + | /// A new account gets a subscription per preference, matching its columns. | |
| 781 | + | #[tokio::test] | |
| 782 | + | async fn signup_lands_a_subscription_for_each_notification() { | |
| 783 | + | let mut h = TestHarness::new().await; | |
| 784 | + | let user = h.signup("prefs1", "prefs1@test.com", "password123").await; | |
| 785 | + | ||
| 786 | + | for (kind, col) in makenotwork::db::lists::NOTIFICATION_LISTS { | |
| 787 | + | let list = lists::find_list( | |
| 788 | + | &h.db, | |
| 789 | + | ListScope::Platform, | |
| 790 | + | None, | |
| 791 | + | kind.parse::<ListKind>().unwrap(), | |
| 792 | + | ) | |
| 793 | + | .await | |
| 794 | + | .unwrap() | |
| 795 | + | .expect("list exists"); | |
| 796 | + | let state: Option<String> = sqlx::query_scalar( | |
| 797 | + | "SELECT state FROM list_subscriptions WHERE list_id = $1 AND user_id = $2", | |
| 798 | + | ) | |
| 799 | + | .bind(list) | |
| 800 | + | .bind(user) | |
| 801 | + | .fetch_optional(&h.db) | |
| 802 | + | .await | |
| 803 | + | .unwrap(); | |
| 804 | + | let expected = if column(&h, user, col).await { | |
| 805 | + | "confirmed" | |
| 806 | + | } else { | |
| 807 | + | "unsubscribed" | |
| 808 | + | }; | |
| 809 | + | assert_eq!( | |
| 810 | + | state.as_deref(), | |
| 811 | + | Some(expected), | |
| 812 | + | "{kind}: subscription disagrees with its column" | |
| 813 | + | ); | |
| 814 | + | } | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | /// Changing a preference in settings moves the subscription with it. | |
| 818 | + | #[tokio::test] | |
| 819 | + | async fn settings_changes_reach_the_subscription() { | |
| 820 | + | let mut h = TestHarness::new().await; | |
| 821 | + | let user = h.signup("prefs2", "prefs2@test.com", "password123").await; | |
| 822 | + | ||
| 823 | + | makenotwork::db::users::disable_notification(&h.db, user, "notify_sale") | |
| 824 | + | .await | |
| 825 | + | .unwrap(); | |
| 826 | + | ||
| 827 | + | let sub = notification_subscription(&h, user, ListKind::Sale).await; | |
| 828 | + | let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1") | |
| 829 | + | .bind(sub) | |
| 830 | + | .fetch_one(&h.db) | |
| 831 | + | .await | |
| 832 | + | .unwrap(); | |
| 833 | + | assert_eq!(state, "unsubscribed"); | |
| 834 | + | assert!(!column(&h, user, "notify_sale").await); | |
| 835 | + | } | |
| 836 | + | ||
| 837 | + | /// The direction that matters: unsubscribing on the preferences page has to | |
| 838 | + | /// reach the column, because the code that decides whether to send reads the | |
| 839 | + | /// column and not the subscription. | |
| 840 | + | #[tokio::test] | |
| 841 | + | async fn unsubscribing_on_the_page_turns_the_column_off() { | |
| 842 | + | let mut h = TestHarness::new().await; | |
| 843 | + | let user = h.signup("prefs3", "prefs3@test.com", "password123").await; | |
| 844 | + | assert!(column(&h, user, "notify_sale").await, "test setup"); | |
| 845 | + | ||
| 846 | + | let sub = notification_subscription(&h, user, ListKind::Sale).await; | |
| 847 | + | let url = prefs_url(sub); | |
| 848 | + | let resp = h.client.post_form(&url, "List-Unsubscribe=One-Click").await; | |
| 849 | + | assert_eq!(resp.status, 200); | |
| 850 | + | ||
| 851 | + | assert!( | |
| 852 | + | !column(&h, user, "notify_sale").await, | |
| 853 | + | "the page unsubscribed them but the send would still fire" | |
| 854 | + | ); | |
| 855 | + | } | |
| 856 | + | ||
| 857 | + | /// Sign-in alerts are a required list. Opting out of being told your account | |
| 858 | + | /// was accessed is not on offer, so one-click refuses and unsubscribe-from-all | |
| 859 | + | /// leaves it alone. | |
| 860 | + | #[tokio::test] | |
| 861 | + | async fn sign_in_alerts_cannot_be_unsubscribed() { | |
| 862 | + | let mut h = TestHarness::new().await; | |
| 863 | + | let user = h.signup("prefs4", "prefs4@test.com", "password123").await; | |
| 864 | + | let login_sub = notification_subscription(&h, user, ListKind::Login).await; | |
| 865 | + | ||
| 866 | + | let resp = h | |
| 867 | + | .client | |
| 868 | + | .post_form(&prefs_url(login_sub), "List-Unsubscribe=One-Click") | |
| 869 | + | .await; | |
| 870 | + | assert!( | |
| 871 | + | resp.status.is_client_error(), | |
| 872 | + | "one-click unsubscribed a security notification" | |
| 873 | + | ); | |
| 874 | + | assert!(column(&h, user, "login_notification_enabled").await); | |
| 875 | + | ||
| 876 | + | // And unsubscribe-from-all skips it while taking the rest. | |
| 877 | + | let sale_sub = notification_subscription(&h, user, ListKind::Sale).await; | |
| 878 | + | let url = prefs_url(sale_sub); | |
| 879 | + | let token = url.split("sub=").nth(1).unwrap(); | |
| 880 | + | let (sub, sig) = token.split_once("&sig=").unwrap(); | |
| 881 | + | h.client | |
| 882 | + | .post_form("/unsubscribe/all", &format!("sub={sub}&sig={sig}")) | |
| 883 | + | .await; | |
| 884 | + | ||
| 885 | + | assert!( | |
| 886 | + | column(&h, user, "login_notification_enabled").await, | |
| 887 | + | "unsubscribe-from-all silenced sign-in alerts" | |
| 888 | + | ); | |
| 889 | + | assert!( | |
| 890 | + | !column(&h, user, "notify_sale").await, | |
| 891 | + | "unsubscribe-from-all left an ordinary preference on" | |
| 892 | + | ); | |
| 893 | + | } |
| @@ -277,6 +277,7 @@ | |||
| 277 | 277 | } | |
| 278 | 278 | // Idempotent: a retried one-click reports success rather than failing. | |
| 279 | 279 | db::lists::unsubscribe(&db, subscription_id, db::ConsentEvent::OptOut).await?; | |
| 280 | + | sync_notification_column(&db, subscription_id, false).await?; | |
| 280 | 281 | return Ok(axum::http::StatusCode::OK.into_response()); | |
| 281 | 282 | } | |
| 282 | 283 | ||
| @@ -334,6 +335,25 @@ | |||
| 334 | 335 | )) | |
| 335 | 336 | } | |
| 336 | 337 | ||
| 338 | + | /// Write a preferences-page change back to the `users.notify_*` column behind | |
| 339 | + | /// it, when there is one. | |
| 340 | + | /// | |
| 341 | + | /// Reads still use those columns, so a subscription change that does not reach | |
| 342 | + | /// the column changes nothing that anyone would notice. No-op for lists with no | |
| 343 | + | /// column, which is every project and repo list. | |
| 344 | + | async fn sync_notification_column( | |
| 345 | + | db: &PgPool, | |
| 346 | + | subscription_id: db::ListSubscriptionId, | |
| 347 | + | enabled: bool, | |
| 348 | + | ) -> Result<()> { | |
| 349 | + | if let Some((user_id, column)) = | |
| 350 | + | db::lists::notification_column_for_subscription(db, subscription_id).await? | |
| 351 | + | { | |
| 352 | + | db::users::set_notification(db, user_id, column, enabled).await?; | |
| 353 | + | } | |
| 354 | + | Ok(()) | |
| 355 | + | } | |
| 356 | + | ||
| 337 | 357 | /// POST /unsubscribe/list: toggle one list from the preferences page. | |
| 338 | 358 | #[tracing::instrument(skip_all, name = "email_actions::preferences_toggle")] | |
| 339 | 359 | pub(super) async fn preferences_toggle( | |
| @@ -366,14 +386,16 @@ | |||
| 366 | 386 | )); | |
| 367 | 387 | } | |
| 368 | 388 | ||
| 369 | - | match form.action.as_deref() { | |
| 370 | - | Some("resubscribe") => { | |
| 371 | - | db::lists::resubscribe(&db, target).await?; | |
| 372 | - | } | |
| 373 | - | _ => { | |
| 374 | - | db::lists::unsubscribe(&db, target, db::ConsentEvent::OptOut).await?; | |
| 375 | - | } | |
| 389 | + | let enabling = form.action.as_deref() == Some("resubscribe"); | |
| 390 | + | if enabling { | |
| 391 | + | db::lists::resubscribe(&db, target).await?; | |
| 392 | + | } else { | |
| 393 | + | db::lists::unsubscribe(&db, target, db::ConsentEvent::OptOut).await?; | |
| 376 | 394 | } | |
| 395 | + | // A notification list is still read from its users.* column by the code | |
| 396 | + | // that decides whether to send. Without this the page would report a | |
| 397 | + | // change it did not make, which is worse than not offering the toggle. | |
| 398 | + | sync_notification_column(&db, target, enabling).await?; | |
| 377 | 399 | ||
| 378 | 400 | // Back to the page, so the change is visible where it was made. | |
| 379 | 401 | Ok( | |
| @@ -392,7 +414,11 @@ | |||
| 392 | 414 | let Some(peer) = verified_subscription(&form.sub, &form.sig, &config) else { | |
| 393 | 415 | return Err(AppError::BadRequest("Invalid link".to_string())); | |
| 394 | 416 | }; | |
| 417 | + | let rows = db::lists::subscriptions_for_peer(&db, peer).await?; | |
| 395 | 418 | let moved = db::lists::unsubscribe_peer_from_all(&db, peer).await?; | |
| 419 | + | for row in rows.iter().filter(|r| !r.required) { | |
| 420 | + | sync_notification_column(&db, row.subscription_id, false).await?; | |
| 421 | + | } | |
| 396 | 422 | ||
| 397 | 423 | Ok(EmailResultTemplate { | |
| 398 | 424 | csrf_token: None, |
| @@ -1,0 +1,89 @@ | |||
| 1 | + | -- Account notification preferences become subscriptions. | |
| 2 | + | -- | |
| 3 | + | -- Step 5 of wiki [[mnw-mailing-lists]]. Seven bool columns on `users` decided | |
| 4 | + | -- who got which notification, while project lists were rows in a table with an | |
| 5 | + | -- unsubscribe path. Same act, two mechanisms, two standards of care. This is | |
| 6 | + | -- the migration that levels them. | |
| 7 | + | -- | |
| 8 | + | -- The task named six columns. There are seven: notify_tip exists too, and it | |
| 9 | + | -- gates a real send (routes/stripe/webhook/checkout_helpers.rs). | |
| 10 | + | -- | |
| 11 | + | -- READS STILL USE THE COLUMNS after this migration, deliberately. Each of those | |
| 12 | + | -- reads is a send decision and there are nine of them across auth, follows, | |
| 13 | + | -- issues and checkout; flipping them in the same release as the data move gives | |
| 14 | + | -- two suspects when something stops arriving. The columns stay authoritative | |
| 15 | + | -- for one deploy and every write keeps both sides in step, so this is | |
| 16 | + | -- reversible by ignoring the new rows. | |
| 17 | + | ||
| 18 | + | -- The notification kinds. 'releases' and 'issues' already exist for project and | |
| 19 | + | -- repo scopes; a platform-scope list of the same kind is a different list, | |
| 20 | + | -- which the (scope, kind) uniqueness already allows. | |
| 21 | + | ALTER TABLE lists DROP CONSTRAINT IF EXISTS lists_kind_check; | |
| 22 | + | ALTER TABLE lists ADD CONSTRAINT lists_kind_check CHECK (kind IN ( | |
| 23 | + | 'content', 'devlog', 'patches', 'releases', | |
| 24 | + | 'issues', 'announce', 'marketing', | |
| 25 | + | 'sale', 'follower', 'login', 'status', 'tip' | |
| 26 | + | )); | |
| 27 | + | ||
| 28 | + | -- One platform list per preference. | |
| 29 | + | -- | |
| 30 | + | -- Sign-in alerts are `required`. Opting out of being told your account was | |
| 31 | + | -- accessed from a new device is not a preference worth offering: it is the | |
| 32 | + | -- notification most likely to be the first sign of a compromise, and the | |
| 33 | + | -- account it protects is the one an attacker would silence first. It keeps its | |
| 34 | + | -- column and its settings toggle for now (see the read note above), but on the | |
| 35 | + | -- unsubscribe page it renders as "Always sent" and unsubscribe-from-all skips | |
| 36 | + | -- it. Decided here rather than inherited by accident. | |
| 37 | + | INSERT INTO lists (scope, scope_id, kind, title, required, owner_id) VALUES | |
| 38 | + | ('platform', NULL, 'sale', 'Sales', FALSE, NULL), | |
| 39 | + | ('platform', NULL, 'follower', 'New followers', FALSE, NULL), | |
| 40 | + | ('platform', NULL, 'releases', 'Releases you follow', FALSE, NULL), | |
| 41 | + | ('platform', NULL, 'issues', 'Issue activity', FALSE, NULL), | |
| 42 | + | ('platform', NULL, 'status', 'Platform status', FALSE, NULL), | |
| 43 | + | ('platform', NULL, 'tip', 'Tips', FALSE, NULL), | |
| 44 | + | ('platform', NULL, 'login', 'Sign-in alerts', TRUE, NULL) | |
| 45 | + | ON CONFLICT DO NOTHING; | |
| 46 | + | ||
| 47 | + | -- Backfill one subscription per user per preference, carrying the column's | |
| 48 | + | -- current value. A false column becomes 'unsubscribed' rather than an absent | |
| 49 | + | -- row: the difference between "said no" and "never asked" is exactly what the | |
| 50 | + | -- consent log exists to record, and an absent row would read as the latter. | |
| 51 | + | INSERT INTO list_subscriptions (list_id, user_id, state, source, created_at, unsubscribed_at) | |
| 52 | + | SELECT l.id, | |
| 53 | + | u.id, | |
| 54 | + | CASE WHEN pref.enabled THEN 'confirmed' ELSE 'unsubscribed' END, | |
| 55 | + | 'import', | |
| 56 | + | u.created_at, | |
| 57 | + | CASE WHEN pref.enabled THEN NULL ELSE NOW() END | |
| 58 | + | FROM users u | |
| 59 | + | CROSS JOIN LATERAL (VALUES | |
| 60 | + | ('sale', u.notify_sale), | |
| 61 | + | ('follower', u.notify_follower), | |
| 62 | + | ('releases', u.notify_release), | |
| 63 | + | ('issues', u.notify_issues), | |
| 64 | + | ('status', u.notify_status), | |
| 65 | + | ('tip', u.notify_tip), | |
| 66 | + | ('login', u.login_notification_enabled) | |
| 67 | + | ) AS pref(kind, enabled) | |
| 68 | + | JOIN lists l ON l.scope = 'platform' AND l.kind = pref.kind | |
| 69 | + | ON CONFLICT DO NOTHING; | |
| 70 | + | ||
| 71 | + | -- Provenance, same honesty rule as the step-2 backfill: these came from a | |
| 72 | + | -- column whose history nobody kept, so the event says import rather than | |
| 73 | + | -- claiming an opt-in with a date we do not have. | |
| 74 | + | INSERT INTO consent_events (subscription_id, event, at, evidence) | |
| 75 | + | SELECT ls.id, | |
| 76 | + | 'import', | |
| 77 | + | ls.created_at, | |
| 78 | + | 'Backfilled from the users.notify_* column of the same name. The columns ' | |
| 79 | + | || 'recorded only the current value, so the date is the account''s and no ' | |
| 80 | + | || 'record of the original choice survives.' | |
| 81 | + | FROM list_subscriptions ls | |
| 82 | + | JOIN lists l ON l.id = ls.list_id | |
| 83 | + | WHERE l.scope = 'platform' | |
| 84 | + | AND l.kind IN ('sale', 'follower', 'releases', 'issues', 'status', 'tip', 'login') | |
| 85 | + | AND ls.source = 'import' | |
| 86 | + | AND NOT EXISTS ( | |
| 87 | + | SELECT 1 FROM consent_events ce | |
| 88 | + | WHERE ce.subscription_id = ls.id AND ce.event = 'import' | |
| 89 | + | ); |
| @@ -1,0 +1,56 @@ | |||
| 1 | + | -- Give every new account its notification subscriptions. | |
| 2 | + | -- | |
| 3 | + | -- 186 backfilled the accounts that existed when it ran. Accounts created after | |
| 4 | + | -- it had no subscription rows at all, so the preferences page showed them | |
| 5 | + | -- nothing and no unsubscribe link could be minted for a notification they were | |
| 6 | + | -- actually receiving. | |
| 7 | + | -- | |
| 8 | + | -- A trigger rather than a line in create_user, because there is no single | |
| 9 | + | -- creation path: create_user, create_sandbox_user, create_example_creator, the | |
| 10 | + | -- seed flow and the test harness all insert into `users` directly. Six call | |
| 11 | + | -- sites that each have to remember is five chances to forget, and the failure | |
| 12 | + | -- is silent. | |
| 13 | + | -- | |
| 14 | + | -- Separate migration rather than an edit to 186, which is already applied. | |
| 15 | + | -- sqlx checksums the whole file and a deployed database rejects boot when one | |
| 16 | + | -- changes, comments included. | |
| 17 | + | CREATE OR REPLACE FUNCTION seed_notification_subscriptions() RETURNS TRIGGER AS $$ | |
| 18 | + | BEGIN | |
| 19 | + | WITH prefs(kind, enabled) AS ( | |
| 20 | + | VALUES | |
| 21 | + | ('sale', NEW.notify_sale), | |
| 22 | + | ('follower', NEW.notify_follower), | |
| 23 | + | ('releases', NEW.notify_release), | |
| 24 | + | ('issues', NEW.notify_issues), | |
| 25 | + | ('status', NEW.notify_status), | |
| 26 | + | ('tip', NEW.notify_tip), | |
| 27 | + | ('login', NEW.login_notification_enabled) | |
| 28 | + | ), | |
| 29 | + | inserted AS ( | |
| 30 | + | INSERT INTO list_subscriptions (list_id, user_id, state, source, unsubscribed_at) | |
| 31 | + | SELECT l.id, | |
| 32 | + | NEW.id, | |
| 33 | + | CASE WHEN prefs.enabled THEN 'confirmed' ELSE 'unsubscribed' END, | |
| 34 | + | 'admin', | |
| 35 | + | CASE WHEN prefs.enabled THEN NULL ELSE NOW() END | |
| 36 | + | FROM prefs | |
| 37 | + | JOIN lists l ON l.scope = 'platform' AND l.kind = prefs.kind | |
| 38 | + | ON CONFLICT DO NOTHING | |
| 39 | + | RETURNING id, state | |
| 40 | + | ) | |
| 41 | + | -- The account default is a choice we made, not one the user made, and the | |
| 42 | + | -- consent log says so rather than recording an opt_in nobody performed. | |
| 43 | + | INSERT INTO consent_events (subscription_id, event, evidence) | |
| 44 | + | SELECT inserted.id, | |
| 45 | + | CASE WHEN inserted.state = 'confirmed' THEN 'opt_in' ELSE 'opt_out' END, | |
| 46 | + | 'Account default at signup, not an explicit choice by the account holder.' | |
| 47 | + | FROM inserted; | |
| 48 | + | ||
| 49 | + | RETURN NEW; | |
| 50 | + | END; | |
| 51 | + | $$ LANGUAGE plpgsql; | |
| 52 | + | ||
| 53 | + | DROP TRIGGER IF EXISTS trg_seed_notification_subscriptions ON users; | |
| 54 | + | CREATE TRIGGER trg_seed_notification_subscriptions | |
| 55 | + | AFTER INSERT ON users | |
| 56 | + | FOR EACH ROW EXECUTE FUNCTION seed_notification_subscriptions(); |