Skip to main content

max / makenotwork

Make account notification preferences subscriptions Step 5 of wiki [[mnw-mailing-lists]], the one with real migration risk. Seven bool columns on `users` decided who got which notification, while project lists were rows in a table with an unsubscribe path. Same act, two mechanisms, two standards of care. The task named six columns. There are seven: notify_tip exists too and gates a real send in the checkout webhook. READS STILL USE THE COLUMNS, deliberately. Each is a send decision and there are nine of them across auth, follows, issues and checkout. Flipping those in the same release as the data move would give two suspects when something stops arriving, so the columns stay authoritative for one deploy and every write keeps both sides in step. That makes this reversible by ignoring the new rows. Both directions are wired, and the second is the one that matters: settings, email unsubscribe link -> column, then subscription preferences page -> subscription, then column Without the write-back, a user could unsubscribe from "Sales" on the preferences page and keep receiving sale notifications, because the send reads the column. A page that reports a change it did not make is worse than one that never offered the toggle. set_notification deliberately does not mirror back, since it is what the subscription side calls; mirroring would loop and log a second consent event for one change. SIGN-IN ALERTS ARE A REQUIRED LIST. Opting out of being told your account was accessed from a new device is not a preference worth offering: it is the notification most likely to be the first sign of a compromise, and the account it protects is the one an attacker would silence first. One-click refuses it and unsubscribe-from-all skips it, both tested. Decided here rather than inherited by accident. 187 adds a trigger seeding the subscriptions for new accounts. 186's backfill only covered accounts that existed when it ran, and there is no single creation path to patch instead: create_user, create_sandbox_user, create_example_creator, the seed flow and the test harness all insert into `users` directly. Six sites that each have to remember is five chances to forget, silently. It is a separate migration rather than an edit to 186 because 186 is already applied, and sqlx checksums the whole file. The backfill records `import`, and the trigger records that a signup default is a choice we made rather than one the account holder made. Neither claims an opt_in nobody performed.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 03:45 UTC
Signed with PGP, not checked
Commit: 293df4440fce9b6f1313a82243f76eae363c09bd
Parent: ca3ed29
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();