max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+804 insertions,
-0 deletions
| @@ -1161,6 +1161,120 @@ | |||
| 1161 | 1161 | } | |
| 1162 | 1162 | } | |
| 1163 | 1163 | ||
| 1164 | + | // ── Mailing lists (wiki: mnw-mailing-lists) ── | |
| 1165 | + | ||
| 1166 | + | /// What a list is attached to. `Platform` lists have no `scope_id`; every other | |
| 1167 | + | /// scope requires one, and the database enforces the pairing. | |
| 1168 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 1169 | + | pub enum ListScope { | |
| 1170 | + | Platform, | |
| 1171 | + | Project, | |
| 1172 | + | Repo, | |
| 1173 | + | Creator, | |
| 1174 | + | } | |
| 1175 | + | ||
| 1176 | + | impl_str_enum!(ListScope { | |
| 1177 | + | Platform => "platform", | |
| 1178 | + | Project => "project", | |
| 1179 | + | Repo => "repo", | |
| 1180 | + | Creator => "creator", | |
| 1181 | + | }); | |
| 1182 | + | ||
| 1183 | + | /// What the list carries. Kinds are deliberately coarse: the unsubscribe page | |
| 1184 | + | /// shows one row per list, and a subscriber who has to reason about fifteen | |
| 1185 | + | /// near-identical kinds will unsubscribe from all of them. | |
| 1186 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 1187 | + | pub enum ListKind { | |
| 1188 | + | Content, | |
| 1189 | + | Devlog, | |
| 1190 | + | Patches, | |
| 1191 | + | Releases, | |
| 1192 | + | Issues, | |
| 1193 | + | Announce, | |
| 1194 | + | Marketing, | |
| 1195 | + | } | |
| 1196 | + | ||
| 1197 | + | impl_str_enum!(ListKind { | |
| 1198 | + | Content => "content", | |
| 1199 | + | Devlog => "devlog", | |
| 1200 | + | Patches => "patches", | |
| 1201 | + | Releases => "releases", | |
| 1202 | + | Issues => "issues", | |
| 1203 | + | Announce => "announce", | |
| 1204 | + | Marketing => "marketing", | |
| 1205 | + | }); | |
| 1206 | + | ||
| 1207 | + | /// Where a subscription stands. | |
| 1208 | + | /// | |
| 1209 | + | /// `Imported` is its own state on purpose. Everything the step-2 backfill | |
| 1210 | + | /// carried over predates any consent record: calling it `Confirmed` would | |
| 1211 | + | /// manufacture evidence we do not have, and calling it `Pending` would assert a | |
| 1212 | + | /// double-opt-in is in flight when none is. Whether an imported subscriber may | |
| 1213 | + | /// be mailed is an open decision, and until it is answered the state says | |
| 1214 | + | /// exactly what is known and no more. | |
| 1215 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 1216 | + | pub enum SubscriptionState { | |
| 1217 | + | Pending, | |
| 1218 | + | Confirmed, | |
| 1219 | + | Imported, | |
| 1220 | + | Unsubscribed, | |
| 1221 | + | Bounced, | |
| 1222 | + | } | |
| 1223 | + | ||
| 1224 | + | impl_str_enum!(SubscriptionState { | |
| 1225 | + | Pending => "pending", | |
| 1226 | + | Confirmed => "confirmed", | |
| 1227 | + | Imported => "imported", | |
| 1228 | + | Unsubscribed => "unsubscribed", | |
| 1229 | + | Bounced => "bounced", | |
| 1230 | + | }); | |
| 1231 | + | ||
| 1232 | + | /// How the subscription was created. Recorded so a later re-confirmation pass | |
| 1233 | + | /// can tell a page subscribe from a bulk import, which the table this replaces | |
| 1234 | + | /// could not. | |
| 1235 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 1236 | + | pub enum SubscriptionSource { | |
| 1237 | + | LandingForm, | |
| 1238 | + | ProjectPage, | |
| 1239 | + | Checkout, | |
| 1240 | + | Import, | |
| 1241 | + | Admin, | |
| 1242 | + | Api, | |
| 1243 | + | } | |
| 1244 | + | ||
| 1245 | + | impl_str_enum!(SubscriptionSource { | |
| 1246 | + | LandingForm => "landing_form", | |
| 1247 | + | ProjectPage => "project_page", | |
| 1248 | + | Checkout => "checkout", | |
| 1249 | + | Import => "import", | |
| 1250 | + | Admin => "admin", | |
| 1251 | + | Api => "api", | |
| 1252 | + | }); | |
| 1253 | + | ||
| 1254 | + | /// An entry in a subscription's consent history. The table is append-only: an | |
| 1255 | + | /// opt-out adds a row rather than editing the opt-in that came before, and a | |
| 1256 | + | /// database trigger rejects `UPDATE` so that stays true. | |
| 1257 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 1258 | + | pub enum ConsentEvent { | |
| 1259 | + | OptIn, | |
| 1260 | + | Confirm, | |
| 1261 | + | OptOut, | |
| 1262 | + | Bounce, | |
| 1263 | + | Complaint, | |
| 1264 | + | AdminRemoval, | |
| 1265 | + | Import, | |
| 1266 | + | } | |
| 1267 | + | ||
| 1268 | + | impl_str_enum!(ConsentEvent { | |
| 1269 | + | OptIn => "opt_in", | |
| 1270 | + | Confirm => "confirm", | |
| 1271 | + | OptOut => "opt_out", | |
| 1272 | + | Bounce => "bounce", | |
| 1273 | + | Complaint => "complaint", | |
| 1274 | + | AdminRemoval => "admin_removal", | |
| 1275 | + | Import => "import", | |
| 1276 | + | }); | |
| 1277 | + | ||
| 1164 | 1278 | #[cfg(test)] | |
| 1165 | 1279 | mod tests { | |
| 1166 | 1280 | use super::*; |
| @@ -177,6 +177,9 @@ | |||
| 177 | 177 | BuildId, | |
| 178 | 178 | MailingListId, | |
| 179 | 179 | MailingListSubscriberId, | |
| 180 | + | ListId, | |
| 181 | + | ListSubscriptionId, | |
| 182 | + | ConsentEventId, | |
| 180 | 183 | CustomDomainId, | |
| 181 | 184 | ItemSectionId, | |
| 182 | 185 | ProjectSectionId, |
| @@ -37,6 +37,7 @@ | |||
| 37 | 37 | pub(crate) mod item_sections; | |
| 38 | 38 | pub mod items; | |
| 39 | 39 | pub mod license_keys; | |
| 40 | + | pub mod lists; | |
| 40 | 41 | pub mod mailing_lists; | |
| 41 | 42 | pub(crate) mod media_files; | |
| 42 | 43 | mod models; |
| @@ -71,6 +71,7 @@ | |||
| 71 | 71 | mod item_sections; | |
| 72 | 72 | mod license_keys; | |
| 73 | 73 | mod lifecycle; | |
| 74 | + | mod lists; | |
| 74 | 75 | mod mailing_lists; | |
| 75 | 76 | mod media_library; | |
| 76 | 77 | mod mock_payment_flows; |
| @@ -1,0 +1,190 @@ | |||
| 1 | + | -- One subscription model for every list-like thing. | |
| 2 | + | -- | |
| 3 | + | -- Step 2 of wiki [[mnw-mailing-lists]]. Today five mechanisms decide who gets | |
| 4 | + | -- email about what and none knows about the others: project mailing_lists, | |
| 5 | + | -- email_signups, six users.notify_* bools, follows, and email_suppressions. | |
| 6 | + | -- Same act, different standards of care. These three tables are the one model | |
| 7 | + | -- the rest collapses into. | |
| 8 | + | -- | |
| 9 | + | -- Nothing reads them yet. Step 3 routes sends through a resolver, step 4 builds | |
| 10 | + | -- the unsubscribe surface, step 5 migrates users.notify_*. The old tables stay | |
| 11 | + | -- authoritative until then, so this migration is additive and reversible by | |
| 12 | + | -- dropping what it creates. | |
| 13 | + | ||
| 14 | + | -- A list is anything somebody can be subscribed to. | |
| 15 | + | CREATE TABLE IF NOT EXISTS lists ( | |
| 16 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 17 | + | scope TEXT NOT NULL CHECK (scope IN ('platform', 'project', 'repo', 'creator')), | |
| 18 | + | -- NULL exactly when the list is platform-wide. The CHECK ties the two | |
| 19 | + | -- together so a project list cannot exist without a project, and a platform | |
| 20 | + | -- list cannot accidentally acquire one. | |
| 21 | + | scope_id UUID, | |
| 22 | + | kind TEXT NOT NULL CHECK (kind IN ( | |
| 23 | + | 'content', 'devlog', 'patches', 'releases', | |
| 24 | + | 'issues', 'announce', 'marketing' | |
| 25 | + | )), | |
| 26 | + | title TEXT NOT NULL, | |
| 27 | + | -- Transactional lists nobody may leave: receipts, security, account | |
| 28 | + | -- lifecycle. This is a property of the LIST rather than of the sender, and | |
| 29 | + | -- it is what structurally keeps a marketing line out of a receipt: if the | |
| 30 | + | -- content is marketing it goes on a marketing list, which is opt-out-able | |
| 31 | + | -- by construction. | |
| 32 | + | required BOOLEAN NOT NULL DEFAULT FALSE, | |
| 33 | + | owner_id UUID REFERENCES users(id) ON DELETE CASCADE, | |
| 34 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 35 | + | CONSTRAINT chk_lists_scope_id CHECK ((scope = 'platform') = (scope_id IS NULL)) | |
| 36 | + | ); | |
| 37 | + | ||
| 38 | + | -- One list per (scope, scope_id, kind). Split in two because scope_id is NULL | |
| 39 | + | -- for platform lists and NULL never equals NULL in a unique index. | |
| 40 | + | CREATE UNIQUE INDEX IF NOT EXISTS idx_lists_scoped | |
| 41 | + | ON lists (scope, scope_id, kind) WHERE scope_id IS NOT NULL; | |
| 42 | + | CREATE UNIQUE INDEX IF NOT EXISTS idx_lists_platform | |
| 43 | + | ON lists (kind) WHERE scope_id IS NULL; | |
| 44 | + | ||
| 45 | + | -- A subscription is one recipient's relationship to one list. | |
| 46 | + | CREATE TABLE IF NOT EXISTS list_subscriptions ( | |
| 47 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 48 | + | list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE, | |
| 49 | + | -- Exactly one of these. A subscriber is either an account or a bare | |
| 50 | + | -- address; the old mailing_list_subscribers allowed both at once, which | |
| 51 | + | -- left "which one is authoritative" to whoever read the row next. | |
| 52 | + | user_id UUID REFERENCES users(id) ON DELETE CASCADE, | |
| 53 | + | email TEXT, | |
| 54 | + | -- 'imported' is the honest state for everything this migration backfills. | |
| 55 | + | -- Those rows predate any consent record, so calling them 'confirmed' would | |
| 56 | + | -- manufacture evidence we do not have, and calling them 'pending' would | |
| 57 | + | -- assert they are mid-double-opt-in, which they are not. Whether an | |
| 58 | + | -- imported subscriber may be mailed is a live decision (GoingsOn | |
| 59 | + | -- 04a882b4); until it is answered, the state says exactly what we know. | |
| 60 | + | state TEXT NOT NULL CHECK (state IN ( | |
| 61 | + | 'pending', 'confirmed', 'imported', 'unsubscribed', 'bounced' | |
| 62 | + | )), | |
| 63 | + | source TEXT NOT NULL CHECK (source IN ( | |
| 64 | + | 'landing_form', 'project_page', 'checkout', | |
| 65 | + | 'import', 'admin', 'api' | |
| 66 | + | )), | |
| 67 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 68 | + | confirmed_at TIMESTAMPTZ, | |
| 69 | + | unsubscribed_at TIMESTAMPTZ, | |
| 70 | + | CONSTRAINT chk_subscription_identity CHECK ((user_id IS NULL) <> (email IS NULL)) | |
| 71 | + | ); | |
| 72 | + | ||
| 73 | + | CREATE UNIQUE INDEX IF NOT EXISTS idx_list_subscriptions_user | |
| 74 | + | ON list_subscriptions (list_id, user_id) WHERE user_id IS NOT NULL; | |
| 75 | + | CREATE UNIQUE INDEX IF NOT EXISTS idx_list_subscriptions_email | |
| 76 | + | ON list_subscriptions (list_id, email) WHERE email IS NOT NULL; | |
| 77 | + | -- The query a send draws from: one list, the states that may receive mail. | |
| 78 | + | CREATE INDEX IF NOT EXISTS idx_list_subscriptions_sendable | |
| 79 | + | ON list_subscriptions (list_id, state); | |
| 80 | + | ||
| 81 | + | -- Why we believe we are allowed to mail this person. Append-only. | |
| 82 | + | CREATE TABLE IF NOT EXISTS consent_events ( | |
| 83 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 84 | + | subscription_id UUID NOT NULL REFERENCES list_subscriptions(id) ON DELETE CASCADE, | |
| 85 | + | event TEXT NOT NULL CHECK (event IN ( | |
| 86 | + | 'opt_in', 'confirm', 'opt_out', 'bounce', | |
| 87 | + | 'complaint', 'admin_removal', 'import' | |
| 88 | + | )), | |
| 89 | + | at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 90 | + | ip INET, | |
| 91 | + | user_agent TEXT, | |
| 92 | + | -- The exact copy shown at opt-in, or the provenance of an import. This is | |
| 93 | + | -- the column that makes consent evidenceable rather than asserted: without | |
| 94 | + | -- it we can say someone subscribed but not what they were told they were | |
| 95 | + | -- subscribing to. | |
| 96 | + | evidence TEXT | |
| 97 | + | ); | |
| 98 | + | ||
| 99 | + | CREATE INDEX IF NOT EXISTS idx_consent_events_subscription | |
| 100 | + | ON consent_events (subscription_id, at DESC); | |
| 101 | + | ||
| 102 | + | -- Append-only, enforced rather than documented. An unsubscribe adds an opt_out | |
| 103 | + | -- row; it never edits the opt_in that came before, because the record of what | |
| 104 | + | -- was agreed to is the whole point of the table. | |
| 105 | + | -- | |
| 106 | + | -- DELETE is deliberately allowed: erasure cascades from list_subscriptions, and | |
| 107 | + | -- a right-to-erasure request has to be able to remove the person. | |
| 108 | + | CREATE OR REPLACE FUNCTION consent_events_no_update() RETURNS TRIGGER AS $$ | |
| 109 | + | BEGIN | |
| 110 | + | RAISE EXCEPTION 'consent_events is append-only; record a new event instead'; | |
| 111 | + | END; | |
| 112 | + | $$ LANGUAGE plpgsql; | |
| 113 | + | ||
| 114 | + | DROP TRIGGER IF EXISTS trg_consent_events_no_update ON consent_events; | |
| 115 | + | CREATE TRIGGER trg_consent_events_no_update | |
| 116 | + | BEFORE UPDATE ON consent_events | |
| 117 | + | FOR EACH ROW EXECUTE FUNCTION consent_events_no_update(); | |
| 118 | + | ||
| 119 | + | -- ── Backfill ── | |
| 120 | + | -- | |
| 121 | + | -- Every existing subscriber lands as 'imported' with an import consent event | |
| 122 | + | -- stating where it came from. None of these rows carries evidence of an opt-in, | |
| 123 | + | -- and the backfill says so rather than inventing one. | |
| 124 | + | ||
| 125 | + | -- Project mailing lists. | |
| 126 | + | INSERT INTO lists (scope, scope_id, kind, title, required, owner_id) | |
| 127 | + | SELECT 'project', ml.project_id, ml.list_type, ml.name, FALSE, p.user_id | |
| 128 | + | FROM mailing_lists ml | |
| 129 | + | JOIN projects p ON p.id = ml.project_id | |
| 130 | + | ON CONFLICT DO NOTHING; | |
| 131 | + | ||
| 132 | + | -- The platform marketing list behind the landing "notify me" form. | |
| 133 | + | INSERT INTO lists (scope, scope_id, kind, title, required, owner_id) | |
| 134 | + | VALUES ('platform', NULL, 'marketing', 'Makenotwork updates', FALSE, NULL) | |
| 135 | + | ON CONFLICT DO NOTHING; | |
| 136 | + | ||
| 137 | + | -- Project subscribers. user_id wins where a legacy row carried both, since an | |
| 138 | + | -- account is the stronger identity and the new CHECK permits only one. | |
| 139 | + | INSERT INTO list_subscriptions (list_id, user_id, email, state, source, created_at) | |
| 140 | + | SELECT l.id, | |
| 141 | + | mls.user_id, | |
| 142 | + | CASE WHEN mls.user_id IS NULL THEN LOWER(mls.email) END, | |
| 143 | + | 'imported', | |
| 144 | + | 'import', | |
| 145 | + | mls.subscribed_at | |
| 146 | + | FROM mailing_list_subscribers mls | |
| 147 | + | JOIN mailing_lists ml ON ml.id = mls.list_id | |
| 148 | + | JOIN lists l ON l.scope = 'project' AND l.scope_id = ml.project_id AND l.kind = ml.list_type | |
| 149 | + | ON CONFLICT DO NOTHING; | |
| 150 | + | ||
| 151 | + | -- Landing signups. An already-unsubscribed row stays unsubscribed: the opt-out | |
| 152 | + | -- is the one piece of consent history this table does have, and losing it in | |
| 153 | + | -- the migration would re-subscribe people who asked to leave. | |
| 154 | + | INSERT INTO list_subscriptions (list_id, email, state, source, created_at, unsubscribed_at) | |
| 155 | + | SELECT l.id, | |
| 156 | + | LOWER(es.email), | |
| 157 | + | CASE WHEN es.unsubscribed_at IS NULL THEN 'imported' ELSE 'unsubscribed' END, | |
| 158 | + | 'import', | |
| 159 | + | es.created_at, | |
| 160 | + | es.unsubscribed_at | |
| 161 | + | FROM email_signups es | |
| 162 | + | CROSS JOIN lists l | |
| 163 | + | WHERE l.scope = 'platform' AND l.kind = 'marketing' | |
| 164 | + | ON CONFLICT DO NOTHING; | |
| 165 | + | ||
| 166 | + | -- One import event per backfilled subscription, naming the table it came from | |
| 167 | + | -- so a later re-confirmation pass can tell the two provenances apart. The | |
| 168 | + | -- project rows came from a subscribe action of unknown shape (the old table | |
| 169 | + | -- kept no source, so a page subscribe and a creator's CSV import are | |
| 170 | + | -- indistinguishable); the signup rows came from the landing form. | |
| 171 | + | INSERT INTO consent_events (subscription_id, event, at, evidence) | |
| 172 | + | SELECT ls.id, | |
| 173 | + | 'import', | |
| 174 | + | ls.created_at, | |
| 175 | + | CASE l.scope | |
| 176 | + | WHEN 'platform' THEN | |
| 177 | + | 'Backfilled from email_signups (landing form, single opt-in, ' | |
| 178 | + | || 'no record of the copy shown at signup).' | |
| 179 | + | ELSE | |
| 180 | + | 'Backfilled from mailing_list_subscribers. The source table kept ' | |
| 181 | + | || 'no provenance, so a page subscribe and a creator CSV import ' | |
| 182 | + | || 'are indistinguishable in this row.' | |
| 183 | + | END | |
| 184 | + | FROM list_subscriptions ls | |
| 185 | + | JOIN lists l ON l.id = ls.list_id | |
| 186 | + | WHERE ls.source = 'import' | |
| 187 | + | AND NOT EXISTS ( | |
| 188 | + | SELECT 1 FROM consent_events ce | |
| 189 | + | WHERE ce.subscription_id = ls.id AND ce.event = 'import' | |
| 190 | + | ); |
| @@ -1,0 +1,204 @@ | |||
| 1 | + | //! One subscription model for every list-like thing. | |
| 2 | + | //! | |
| 3 | + | //! Step 2 of the plan in the maintainer wiki (`mnw-mailing-lists`). Three | |
| 4 | + | //! tables: a `list` is anything somebody can be subscribed to, a | |
| 5 | + | //! `list_subscription` is one recipient's relationship to one list, and a | |
| 6 | + | //! `consent_event` is why we believe we are allowed to mail them. | |
| 7 | + | //! | |
| 8 | + | //! **Nothing sends through this yet.** `mailing_lists` and `email_signups` | |
| 9 | + | //! remain authoritative until the resolver lands (step 3). This module exists | |
| 10 | + | //! so the schema has typed access and the backfill has tests, not so callers | |
| 11 | + | //! can start using it piecemeal, which is how the five parallel mechanisms this | |
| 12 | + | //! replaces came to exist in the first place. | |
| 13 | + | //! <!-- wiki: mnw-mailing-lists --> | |
| 14 | + | ||
| 15 | + | use sqlx::PgPool; | |
| 16 | + | ||
| 17 | + | use super::enums::{ConsentEvent, ListKind, ListScope, SubscriptionSource, SubscriptionState}; | |
| 18 | + | use super::id_types::{ListId, ListSubscriptionId, UserId}; | |
| 19 | + | use crate::error::Result; | |
| 20 | + | ||
| 21 | + | /// A subscriber: an account or a bare address, never both. The old | |
| 22 | + | /// `mailing_list_subscribers` allowed both at once and left "which one is | |
| 23 | + | /// authoritative" to whoever read the row next; the new table's CHECK makes | |
| 24 | + | /// that unrepresentable, and this type carries the same rule into Rust. | |
| 25 | + | #[derive(Debug, Clone)] | |
| 26 | + | pub enum Subscriber { | |
| 27 | + | User(UserId), | |
| 28 | + | Email(String), | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// Find a list by what it is attached to. | |
| 32 | + | /// | |
| 33 | + | /// `scope_id` must be `None` for [`ListScope::Platform`] and `Some` otherwise; | |
| 34 | + | /// the database CHECK rejects the other combinations, so a mismatch here | |
| 35 | + | /// returns no row rather than the wrong one. | |
| 36 | + | #[tracing::instrument(skip_all)] | |
| 37 | + | pub async fn find_list( | |
| 38 | + | pool: &PgPool, | |
| 39 | + | scope: ListScope, | |
| 40 | + | scope_id: Option<uuid::Uuid>, | |
| 41 | + | kind: ListKind, | |
| 42 | + | ) -> Result<Option<ListId>> { | |
| 43 | + | let id = sqlx::query_scalar::<_, ListId>( | |
| 44 | + | "SELECT id FROM lists \ | |
| 45 | + | WHERE scope = $1 AND kind = $2 \ | |
| 46 | + | AND (scope_id = $3 OR ($3::uuid IS NULL AND scope_id IS NULL))", | |
| 47 | + | ) | |
| 48 | + | .bind(scope.to_string()) | |
| 49 | + | .bind(kind.to_string()) | |
| 50 | + | .bind(scope_id) | |
| 51 | + | .fetch_optional(pool) | |
| 52 | + | .await?; | |
| 53 | + | Ok(id) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// Record a subscription and the consent event that justifies it, in one | |
| 57 | + | /// transaction. | |
| 58 | + | /// | |
| 59 | + | /// The two are written together because a subscription without its consent | |
| 60 | + | /// event is exactly the state this whole model exists to eliminate: someone on | |
| 61 | + | /// a list with no record of why. Re-subscribing an address that had left moves | |
| 62 | + | /// it back and appends a fresh event rather than editing the old one. | |
| 63 | + | #[tracing::instrument(skip_all)] | |
| 64 | + | pub async fn subscribe( | |
| 65 | + | pool: &PgPool, | |
| 66 | + | list_id: ListId, | |
| 67 | + | subscriber: &Subscriber, | |
| 68 | + | state: SubscriptionState, | |
| 69 | + | source: SubscriptionSource, | |
| 70 | + | event: ConsentEvent, | |
| 71 | + | evidence: Option<&str>, | |
| 72 | + | ) -> Result<ListSubscriptionId> { | |
| 73 | + | let (user_id, email) = match subscriber { | |
| 74 | + | Subscriber::User(id) => (Some(*id), None), | |
| 75 | + | Subscriber::Email(addr) => (None, Some(addr.to_lowercase())), | |
| 76 | + | }; | |
| 77 | + | let confirmed_at = (state == SubscriptionState::Confirmed).then(chrono::Utc::now); | |
| 78 | + | ||
| 79 | + | let mut tx = pool.begin().await?; | |
| 80 | + | ||
| 81 | + | // The uniqueness of a subscriber is enforced by two partial indexes, one | |
| 82 | + | // per identity kind, and ON CONFLICT has to name the one that applies. | |
| 83 | + | // A single statement cannot cover both, so the arm is chosen here rather | |
| 84 | + | // than left to the database to guess. | |
| 85 | + | let conflict_target = match subscriber { | |
| 86 | + | Subscriber::User(_) => "(list_id, user_id) WHERE user_id IS NOT NULL", | |
| 87 | + | Subscriber::Email(_) => "(list_id, email) WHERE email IS NOT NULL", | |
| 88 | + | }; | |
| 89 | + | let sql = format!( | |
| 90 | + | "INSERT INTO list_subscriptions (list_id, user_id, email, state, source, confirmed_at) \ | |
| 91 | + | VALUES ($1, $2, $3, $4, $5, $6) \ | |
| 92 | + | ON CONFLICT {conflict_target} \ | |
| 93 | + | DO UPDATE SET state = EXCLUDED.state, \ | |
| 94 | + | confirmed_at = EXCLUDED.confirmed_at, \ | |
| 95 | + | unsubscribed_at = NULL \ | |
| 96 | + | RETURNING id" | |
| 97 | + | ); | |
| 98 | + | ||
| 99 | + | let subscription_id = sqlx::query_scalar::<_, ListSubscriptionId>(&sql) | |
| 100 | + | .bind(list_id) | |
| 101 | + | .bind(user_id) | |
| 102 | + | .bind(email.as_deref()) | |
| 103 | + | .bind(state.to_string()) | |
| 104 | + | .bind(source.to_string()) | |
| 105 | + | .bind(confirmed_at) | |
| 106 | + | .fetch_one(&mut *tx) | |
| 107 | + | .await?; | |
| 108 | + | ||
| 109 | + | sqlx::query( | |
| 110 | + | "INSERT INTO consent_events (subscription_id, event, evidence) VALUES ($1, $2, $3)", | |
| 111 | + | ) | |
| 112 | + | .bind(subscription_id) | |
| 113 | + | .bind(event.to_string()) | |
| 114 | + | .bind(evidence) | |
| 115 | + | .execute(&mut *tx) | |
| 116 | + | .await?; | |
| 117 | + | ||
| 118 | + | tx.commit().await?; | |
| 119 | + | Ok(subscription_id) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// Mark a subscription unsubscribed and append the opt-out event. | |
| 123 | + | /// | |
| 124 | + | /// Returns whether a subscription moved. Idempotent: unsubscribing twice | |
| 125 | + | /// reports `false` the second time and is not an error, which matters because | |
| 126 | + | /// RFC 8058 one-click POSTs get retried. | |
| 127 | + | #[tracing::instrument(skip_all)] | |
| 128 | + | pub async fn unsubscribe( | |
| 129 | + | pool: &PgPool, | |
| 130 | + | subscription_id: ListSubscriptionId, | |
| 131 | + | event: ConsentEvent, | |
| 132 | + | ) -> Result<bool> { | |
| 133 | + | let mut tx = pool.begin().await?; | |
| 134 | + | ||
| 135 | + | let moved = sqlx::query( | |
| 136 | + | "UPDATE list_subscriptions SET state = 'unsubscribed', unsubscribed_at = NOW() \ | |
| 137 | + | WHERE id = $1 AND state <> 'unsubscribed'", | |
| 138 | + | ) | |
| 139 | + | .bind(subscription_id) | |
| 140 | + | .execute(&mut *tx) | |
| 141 | + | .await? | |
| 142 | + | .rows_affected() | |
| 143 | + | > 0; | |
| 144 | + | ||
| 145 | + | if moved { | |
| 146 | + | sqlx::query("INSERT INTO consent_events (subscription_id, event) VALUES ($1, $2)") | |
| 147 | + | .bind(subscription_id) | |
| 148 | + | .bind(event.to_string()) | |
| 149 | + | .execute(&mut *tx) | |
| 150 | + | .await?; | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | tx.commit().await?; | |
| 154 | + | Ok(moved) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// Count subscriptions on a list in a given state. Exists for the backfill | |
| 158 | + | /// tests and the admin view; the send path gets its own resolver in step 3. | |
| 159 | + | #[tracing::instrument(skip_all)] | |
| 160 | + | pub async fn count_in_state( | |
| 161 | + | pool: &PgPool, | |
| 162 | + | list_id: ListId, | |
| 163 | + | state: SubscriptionState, | |
| 164 | + | ) -> Result<i64> { | |
| 165 | + | let count = sqlx::query_scalar::<_, i64>( | |
| 166 | + | "SELECT COUNT(*) FROM list_subscriptions WHERE list_id = $1 AND state = $2", | |
| 167 | + | ) | |
| 168 | + | .bind(list_id) | |
| 169 | + | .bind(state.to_string()) | |
| 170 | + | .fetch_one(pool) | |
| 171 | + | .await?; | |
| 172 | + | Ok(count) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | #[cfg(test)] | |
| 176 | + | mod tests { | |
| 177 | + | use super::*; | |
| 178 | + | ||
| 179 | + | #[test] | |
| 180 | + | fn scope_and_kind_round_trip() { | |
| 181 | + | for s in [ | |
| 182 | + | ListScope::Platform, | |
| 183 | + | ListScope::Project, | |
| 184 | + | ListScope::Repo, | |
| 185 | + | ListScope::Creator, | |
| 186 | + | ] { | |
| 187 | + | assert_eq!(s.to_string().parse::<ListScope>().unwrap(), s); | |
| 188 | + | } | |
| 189 | + | for k in [ListKind::Content, ListKind::Marketing, ListKind::Issues] { | |
| 190 | + | assert_eq!(k.to_string().parse::<ListKind>().unwrap(), k); | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// The strings are a database CHECK constraint, so a rename here that is | |
| 195 | + | /// not matched by a migration fails at insert rather than at compile time. | |
| 196 | + | #[test] | |
| 197 | + | fn state_and_event_strings_match_the_check_constraints() { | |
| 198 | + | assert_eq!(SubscriptionState::Imported.to_string(), "imported"); | |
| 199 | + | assert_eq!(SubscriptionState::Unsubscribed.to_string(), "unsubscribed"); | |
| 200 | + | assert_eq!(SubscriptionSource::LandingForm.to_string(), "landing_form"); | |
| 201 | + | assert_eq!(ConsentEvent::AdminRemoval.to_string(), "admin_removal"); | |
| 202 | + | assert_eq!(ConsentEvent::Import.to_string(), "import"); | |
| 203 | + | } | |
| 204 | + | } |
| @@ -1,0 +1,291 @@ | |||
| 1 | + | //! The unified subscription model (step 2 of wiki `mnw-mailing-lists`). | |
| 2 | + | //! | |
| 3 | + | //! Nothing sends through these tables yet, so what is worth pinning is the | |
| 4 | + | //! shape: the constraints that make bad states unrepresentable, and the | |
| 5 | + | //! append-only guarantee the consent log rests on. Those are the parts a later | |
| 6 | + | //! step will lean on without re-checking. | |
| 7 | + | ||
| 8 | + | use crate::harness::TestHarness; | |
| 9 | + | use makenotwork::db::{ | |
| 10 | + | ConsentEvent, ListKind, ListScope, SubscriptionSource, SubscriptionState, lists, | |
| 11 | + | }; | |
| 12 | + | ||
| 13 | + | /// The backfill creates the platform marketing list unconditionally, so the | |
| 14 | + | /// landing form has somewhere to point once step 3 wires it up. | |
| 15 | + | #[tokio::test] | |
| 16 | + | async fn migration_creates_the_platform_marketing_list() { | |
| 17 | + | let h = TestHarness::new().await; | |
| 18 | + | ||
| 19 | + | let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) | |
| 20 | + | .await | |
| 21 | + | .expect("query") | |
| 22 | + | .expect("platform marketing list should exist after the backfill"); | |
| 23 | + | ||
| 24 | + | let count = lists::count_in_state(&h.db, list, SubscriptionState::Imported) | |
| 25 | + | .await | |
| 26 | + | .expect("count"); | |
| 27 | + | assert_eq!(count, 0, "a fresh database has nothing to import"); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// A subscriber is an account or a bare address, never both and never neither. | |
| 31 | + | /// The old mailing_list_subscribers allowed both at once, which left "which is | |
| 32 | + | /// authoritative" to whoever read the row next. | |
| 33 | + | #[tokio::test] | |
| 34 | + | async fn a_subscription_cannot_have_both_identities_or_neither() { | |
| 35 | + | let h = TestHarness::new().await; | |
| 36 | + | let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) | |
| 37 | + | .await | |
| 38 | + | .unwrap() | |
| 39 | + | .unwrap(); | |
| 40 | + | ||
| 41 | + | let both = sqlx::query( | |
| 42 | + | "INSERT INTO list_subscriptions (list_id, user_id, email, state, source) \ | |
| 43 | + | VALUES ($1, gen_random_uuid(), 'both@example.com', 'confirmed', 'api')", | |
| 44 | + | ) | |
| 45 | + | .bind(list) | |
| 46 | + | .execute(&h.db) | |
| 47 | + | .await; | |
| 48 | + | assert!(both.is_err(), "a row with both identities was accepted"); | |
| 49 | + | ||
| 50 | + | let neither = sqlx::query( | |
| 51 | + | "INSERT INTO list_subscriptions (list_id, state, source) VALUES ($1, 'confirmed', 'api')", | |
| 52 | + | ) | |
| 53 | + | .bind(list) | |
| 54 | + | .execute(&h.db) | |
| 55 | + | .await; | |
| 56 | + | assert!(neither.is_err(), "a row with no identity was accepted"); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// scope_id is NULL exactly for platform lists. A project list without a | |
| 60 | + | /// project, or a platform list that acquired one, is not a representable state. | |
| 61 | + | #[tokio::test] | |
| 62 | + | async fn list_scope_and_scope_id_must_agree() { | |
| 63 | + | let h = TestHarness::new().await; | |
| 64 | + | ||
| 65 | + | let platform_with_id = sqlx::query( | |
| 66 | + | "INSERT INTO lists (scope, scope_id, kind, title) \ | |
| 67 | + | VALUES ('platform', gen_random_uuid(), 'announce', 'bad')", | |
| 68 | + | ) | |
| 69 | + | .execute(&h.db) | |
| 70 | + | .await; | |
| 71 | + | assert!(platform_with_id.is_err()); | |
| 72 | + | ||
| 73 | + | let project_without_id = | |
| 74 | + | sqlx::query("INSERT INTO lists (scope, kind, title) VALUES ('project', 'content', 'bad')") | |
| 75 | + | .execute(&h.db) | |
| 76 | + | .await; | |
| 77 | + | assert!(project_without_id.is_err()); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// Subscribing writes the subscription and its consent event together. A | |
| 81 | + | /// subscription with no recorded reason is the exact state this model exists to | |
| 82 | + | /// eliminate. | |
| 83 | + | #[tokio::test] | |
| 84 | + | async fn subscribing_records_the_consent_event_with_it() { | |
| 85 | + | let h = TestHarness::new().await; | |
| 86 | + | let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) | |
| 87 | + | .await | |
| 88 | + | .unwrap() | |
| 89 | + | .unwrap(); | |
| 90 | + | ||
| 91 | + | let sub = lists::subscribe( | |
| 92 | + | &h.db, | |
| 93 | + | list, | |
| 94 | + | &lists::Subscriber::Email("consent@example.com".to_string()), | |
| 95 | + | SubscriptionState::Confirmed, | |
| 96 | + | SubscriptionSource::LandingForm, | |
| 97 | + | ConsentEvent::OptIn, | |
| 98 | + | Some("Get notified when something ships."), | |
| 99 | + | ) | |
| 100 | + | .await | |
| 101 | + | .expect("subscribe"); | |
| 102 | + | ||
| 103 | + | let (event, evidence): (String, Option<String>) = | |
| 104 | + | sqlx::query_as("SELECT event, evidence FROM consent_events WHERE subscription_id = $1") | |
| 105 | + | .bind(sub) | |
| 106 | + | .fetch_one(&h.db) | |
| 107 | + | .await | |
| 108 | + | .expect("consent event"); | |
| 109 | + | assert_eq!(event, "opt_in"); | |
| 110 | + | assert_eq!( | |
| 111 | + | evidence.as_deref(), | |
| 112 | + | Some("Get notified when something ships."), | |
| 113 | + | "the copy shown at opt-in is what makes the consent evidenceable" | |
| 114 | + | ); | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// Unsubscribing appends rather than edits, and is idempotent because one-click | |
| 118 | + | /// POSTs get retried. | |
| 119 | + | #[tokio::test] | |
| 120 | + | async fn unsubscribing_appends_an_event_and_is_idempotent() { | |
| 121 | + | let h = TestHarness::new().await; | |
| 122 | + | let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) | |
| 123 | + | .await | |
| 124 | + | .unwrap() | |
| 125 | + | .unwrap(); | |
| 126 | + | ||
| 127 | + | let sub = lists::subscribe( | |
| 128 | + | &h.db, | |
| 129 | + | list, | |
| 130 | + | &lists::Subscriber::Email("leaving@example.com".to_string()), | |
| 131 | + | SubscriptionState::Confirmed, | |
| 132 | + | SubscriptionSource::LandingForm, | |
| 133 | + | ConsentEvent::OptIn, | |
| 134 | + | None, | |
| 135 | + | ) | |
| 136 | + | .await | |
| 137 | + | .unwrap(); | |
| 138 | + | ||
| 139 | + | assert!( | |
| 140 | + | lists::unsubscribe(&h.db, sub, ConsentEvent::OptOut) | |
| 141 | + | .await | |
| 142 | + | .unwrap() | |
| 143 | + | ); | |
| 144 | + | assert!( | |
| 145 | + | !lists::unsubscribe(&h.db, sub, ConsentEvent::OptOut) | |
| 146 | + | .await | |
| 147 | + | .unwrap(), | |
| 148 | + | "a retried unsubscribe must report no change rather than erroring" | |
| 149 | + | ); | |
| 150 | + | ||
| 151 | + | // The opt_in survives the opt_out: the record of what was agreed to is the | |
| 152 | + | // point of the table. | |
| 153 | + | let events: Vec<String> = sqlx::query_scalar( | |
| 154 | + | "SELECT event FROM consent_events WHERE subscription_id = $1 ORDER BY at", | |
| 155 | + | ) | |
| 156 | + | .bind(sub) | |
| 157 | + | .fetch_all(&h.db) | |
| 158 | + | .await | |
| 159 | + | .unwrap(); | |
| 160 | + | assert_eq!(events, vec!["opt_in".to_string(), "opt_out".to_string()]); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// Re-subscribing moves the row back and appends a fresh event, rather than | |
| 164 | + | /// rewriting the history that says they once left. | |
| 165 | + | #[tokio::test] | |
| 166 | + | async fn resubscribing_restores_the_row_and_keeps_the_history() { | |
| 167 | + | let h = TestHarness::new().await; | |
| 168 | + | let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) | |
| 169 | + | .await | |
| 170 | + | .unwrap() | |
| 171 | + | .unwrap(); | |
| 172 | + | let subscriber = lists::Subscriber::Email("returning@example.com".to_string()); | |
| 173 | + | ||
| 174 | + | let sub = lists::subscribe( | |
| 175 | + | &h.db, | |
| 176 | + | list, | |
| 177 | + | &subscriber, | |
| 178 | + | SubscriptionState::Confirmed, | |
| 179 | + | SubscriptionSource::LandingForm, | |
| 180 | + | ConsentEvent::OptIn, | |
| 181 | + | None, | |
| 182 | + | ) | |
| 183 | + | .await | |
| 184 | + | .unwrap(); | |
| 185 | + | lists::unsubscribe(&h.db, sub, ConsentEvent::OptOut) | |
| 186 | + | .await | |
| 187 | + | .unwrap(); | |
| 188 | + | ||
| 189 | + | let again = lists::subscribe( | |
| 190 | + | &h.db, | |
| 191 | + | list, | |
| 192 | + | &subscriber, | |
| 193 | + | SubscriptionState::Confirmed, | |
| 194 | + | SubscriptionSource::LandingForm, | |
| 195 | + | ConsentEvent::OptIn, | |
| 196 | + | None, | |
| 197 | + | ) | |
| 198 | + | .await | |
| 199 | + | .expect("re-subscribe should update rather than conflict"); | |
| 200 | + | assert_eq!(again, sub, "re-subscribing should reuse the row"); | |
| 201 | + | ||
| 202 | + | assert_eq!( | |
| 203 | + | lists::count_in_state(&h.db, list, SubscriptionState::Confirmed) | |
| 204 | + | .await | |
| 205 | + | .unwrap(), | |
| 206 | + | 1 | |
| 207 | + | ); | |
| 208 | + | let events: i64 = | |
| 209 | + | sqlx::query_scalar("SELECT COUNT(*) FROM consent_events WHERE subscription_id = $1") | |
| 210 | + | .bind(sub) | |
| 211 | + | .fetch_one(&h.db) | |
| 212 | + | .await | |
| 213 | + | .unwrap(); | |
| 214 | + | assert_eq!(events, 3, "opt_in, opt_out, opt_in"); | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | /// The append-only guarantee is enforced by the database, not by convention. | |
| 218 | + | /// Every consent claim rests on the log not having been edited after the fact. | |
| 219 | + | #[tokio::test] | |
| 220 | + | async fn consent_events_reject_updates() { | |
| 221 | + | let h = TestHarness::new().await; | |
| 222 | + | let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) | |
| 223 | + | .await | |
| 224 | + | .unwrap() | |
| 225 | + | .unwrap(); | |
| 226 | + | let sub = lists::subscribe( | |
| 227 | + | &h.db, | |
| 228 | + | list, | |
| 229 | + | &lists::Subscriber::Email("immutable@example.com".to_string()), | |
| 230 | + | SubscriptionState::Confirmed, | |
| 231 | + | SubscriptionSource::LandingForm, | |
| 232 | + | ConsentEvent::OptIn, | |
| 233 | + | Some("original copy"), | |
| 234 | + | ) | |
| 235 | + | .await | |
| 236 | + | .unwrap(); | |
| 237 | + | ||
| 238 | + | let rewrite = | |
| 239 | + | sqlx::query("UPDATE consent_events SET evidence = 'rewritten' WHERE subscription_id = $1") | |
| 240 | + | .bind(sub) | |
| 241 | + | .execute(&h.db) | |
| 242 | + | .await; | |
| 243 | + | assert!(rewrite.is_err(), "consent history was editable"); | |
| 244 | + | ||
| 245 | + | let evidence: String = | |
| 246 | + | sqlx::query_scalar("SELECT evidence FROM consent_events WHERE subscription_id = $1") | |
| 247 | + | .bind(sub) | |
| 248 | + | .fetch_one(&h.db) | |
| 249 | + | .await | |
| 250 | + | .unwrap(); | |
| 251 | + | assert_eq!(evidence, "original copy"); | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | /// Erasure has to be able to remove the person, so DELETE cascades even though | |
| 255 | + | /// UPDATE is blocked. This is the one way consent rows legitimately go away. | |
| 256 | + | #[tokio::test] | |
| 257 | + | async fn erasing_a_subscription_takes_its_consent_history_with_it() { | |
| 258 | + | let h = TestHarness::new().await; | |
| 259 | + | let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) | |
| 260 | + | .await | |
| 261 | + | .unwrap() | |
| 262 | + | .unwrap(); | |
| 263 | + | let sub = lists::subscribe( | |
| 264 | + | &h.db, | |
| 265 | + | list, | |
| 266 | + | &lists::Subscriber::Email("erase@example.com".to_string()), | |
| 267 | + | SubscriptionState::Confirmed, | |
| 268 | + | SubscriptionSource::LandingForm, | |
| 269 | + | ConsentEvent::OptIn, | |
| 270 | + | None, | |
| 271 | + | ) | |
| 272 | + | .await | |
| 273 | + | .unwrap(); | |
| 274 | + | ||
| 275 | + | sqlx::query("DELETE FROM list_subscriptions WHERE id = $1") | |
| 276 | + | .bind(sub) | |
| 277 | + | .execute(&h.db) | |
| 278 | + | .await | |
| 279 | + | .expect("erasure must be possible"); | |
| 280 | + | ||
| 281 | + | let left: i64 = | |
| 282 | + | sqlx::query_scalar("SELECT COUNT(*) FROM consent_events WHERE subscription_id = $1") | |
| 283 | + | .bind(sub) | |
| 284 | + | .fetch_one(&h.db) | |
| 285 | + | .await | |
| 286 | + | .unwrap(); | |
| 287 | + | assert_eq!( | |
| 288 | + | left, 0, | |
| 289 | + | "consent rows outlived the subscription they described" | |
| 290 | + | ); | |
| 291 | + | } |