Skip to main content

cratefield_module_waitlist/
lib.rs

1//! `cratefield-module-waitlist`: per-product waitlist with confirm,
2//! position, referral codes and status (issue #11, architecture section
3//! 6).
4//!
5//! ```no_run
6//! use cratefield_module_waitlist::Waitlist;
7//!
8//! let module = Waitlist::new()
9//!     .products(["kontinuum", "undercover-rockstars"])
10//!     .confirm_ttl_days(7)
11//!     .referrals(true);
12//! ```
13//!
14//! **Positions are atomic.** Confirmation reads the product's position
15//! from a per-product counter on `waitlist_position_lock` that the
16//! lock-taking UPDATE increments in the same statement, and that UPDATE
17//! plus the flip and the referrer credit run in one
18//! [`cratefield_core::Database::batch_atomic`] — atomic on D1, a
19//! transaction on the sqlite and Postgres adapters — so concurrent
20//! confirms of one product serialize on every engine and a UNIQUE
21//! (product, position) index means a duplicate position cannot survive
22//! even if the allocation raced (issues #20, #173, #126). Positions are
23//! dense at assign time per product
24//! and never recomputed on delete.
25//!
26//! **No enumeration** (section 11): `POST /v1/waitlist` answers the same
27//! `202 {"ok":true}` bytes whatever the row state; an unknown `ref` code
28//! is silently ignored.
29
30#![doc = include_str!("../README.md")]
31#![forbid(unsafe_code)]
32
33mod handlers;
34mod mail;
35mod store;
36
37pub use mail::{ConfirmMailData, ConfirmedMailData, default_templates};
38
39use cratefield_core::{
40    AnyError, BoxFuture, Config, ConfigError, DataKind, Disposition, Migrations, Module,
41    ModuleConfig, ModuleContext, PersonalDataSet, Port, SqlMigration,
42};
43use serde_json::Value;
44use std::sync::Arc;
45
46use handlers::{Products, Settings};
47
48/// A venture's validation for the free-form `answers` field of a join.
49/// `Err(detail)` becomes a `400 validation-failed` problem.
50pub type AnswersSchema = Arc<dyn Fn(&Value) -> Result<(), String> + Send + Sync>;
51
52fn accept_any_answers() -> AnswersSchema {
53    Arc::new(|_answers| Ok(()))
54}
55
56/// The module's migrations: the `waitlist_entries` table in the portable
57/// SQL subset (issue #11), plus the entry generation that confirm tokens
58/// bind to (issue #127).
59const MIGRATION_INIT: SqlMigration = SqlMigration::new(
60    "0001",
61    "init",
62    include_str!("../migrations/sqlite/0001_init.sql"),
63);
64
65const MIGRATION_ENTRY_GENERATION: SqlMigration = SqlMigration::new(
66    "0002",
67    "entry_generation",
68    include_str!("../migrations/sqlite/0002_entry_generation.sql"),
69);
70
71/// The durable send-cooldown table backing the one-mail-per-window claim
72/// (issue #133). Name must match `handlers::SEND_COOLDOWN_TABLE`.
73const MIGRATION_MAIL_COOLDOWN: SqlMigration = SqlMigration::new(
74    "0003",
75    "mail_cooldown",
76    include_str!("../migrations/sqlite/0003_mail_cooldown.sql"),
77);
78
79/// The single-row per-product position mutex used by `confirm_entry`
80/// (issue #173). Name must match the table in `store::confirm_entry`.
81const MIGRATION_POSITION_LOCK: SqlMigration = SqlMigration::new(
82    "0004",
83    "position_lock",
84    include_str!("../migrations/sqlite/0004_position_lock.sql"),
85);
86
87/// The address columns made nullable so an entry can be anonymised rather
88/// than deleted (issue #265). See the migration for why deleting the row is
89/// the wrong answer here.
90const MIGRATION_ANONYMISABLE_ENTRY: SqlMigration = SqlMigration::new(
91    "0005",
92    "anonymisable_entry",
93    include_str!("../migrations/sqlite/0005_anonymisable_entry.sql"),
94);
95
96/// The per-product position counter and its UNIQUE backstop (issue #126).
97const MIGRATION_POSITION_COUNTER: SqlMigration = SqlMigration::new(
98    "0006",
99    "position_counter",
100    include_str!("../migrations/sqlite/0006_position_counter.sql"),
101);
102
103/// A per-product waitlist.
104pub struct Waitlist {
105    settings: Settings,
106}
107
108impl Default for Waitlist {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114impl Waitlist {
115    /// No products allowed until [`Waitlist::products`] or
116    /// [`Waitlist::any_product`] says otherwise; 7-day confirm TTL;
117    /// referrals on.
118    pub fn new() -> Self {
119        Self {
120            settings: Settings {
121                products: Products::List(Vec::new()),
122                confirm_ttl_days: 7,
123                retention_days_pending: 30,
124                status_redirect: None,
125                referrals: true,
126                answers_schema: accept_any_answers(),
127            },
128        }
129    }
130
131    /// Exactly these product slugs may be joined.
132    #[must_use]
133    pub fn products(mut self, products: impl IntoIterator<Item = impl Into<String>>) -> Self {
134        self.settings.products = Products::List(products.into_iter().map(Into::into).collect());
135        self
136    }
137
138    /// Accept any product slug (single-product ventures that do not want
139    /// an allowlist).
140    #[must_use]
141    pub fn any_product(mut self) -> Self {
142        self.settings.products = Products::Any;
143        self
144    }
145
146    /// How long a confirmation link stays valid (default 7 days).
147    #[must_use]
148    pub fn confirm_ttl_days(mut self, days: u32) -> Self {
149        self.settings.confirm_ttl_days = days;
150        self
151    }
152
153    /// Purge `pending` entries untouched for this many days from the
154    /// scheduled handler (default 30; issue #13 retention rule).
155    #[must_use]
156    pub fn retention_days_pending(mut self, days: u32) -> Self {
157        self.settings.retention_days_pending = days;
158        self
159    }
160
161    /// Where confirmation redirects to, as
162    /// `<url>?token=<status token>` (default
163    /// `<public_url>/waitlist/status`).
164    #[must_use]
165    pub fn status_redirect(mut self, url: impl Into<String>) -> Self {
166        self.settings.status_redirect = Some(url.into());
167        self
168    }
169
170    /// Whether `ref` codes credit referrers (default `true`).
171    #[must_use]
172    pub fn referrals(mut self, enabled: bool) -> Self {
173        self.settings.referrals = enabled;
174        self
175    }
176
177    /// Validates the free-form `answers` JSON of a join (default: accept
178    /// anything).
179    #[must_use]
180    pub fn answers_schema(
181        mut self,
182        validate: impl Fn(&Value) -> Result<(), String> + Send + Sync + 'static,
183    ) -> Self {
184        self.settings.answers_schema = Arc::new(validate);
185        self
186    }
187}
188
189impl Module for Waitlist {
190    fn name(&self) -> &'static str {
191        "waitlist"
192    }
193
194    fn version(&self) -> &'static str {
195        env!("CARGO_PKG_VERSION")
196    }
197
198    fn requires(&self) -> &'static [Port] {
199        &[Port::Db, Port::Mailer, Port::Signer]
200    }
201
202    fn optional(&self) -> &'static [Port] {
203        &[Port::Captcha, Port::RateLimiter]
204    }
205
206    fn tables(&self) -> &'static [&'static str] {
207        &[
208            "waitlist_entries",
209            "waitlist_send_cooldown",
210            "waitlist_position_lock",
211        ]
212    }
213
214    /// The entry is anonymised, not deleted; the other two tables have no
215    /// subject column at all (issue #265).
216    ///
217    /// **Why `Anonymise` and not `Erase`.** A confirmed entry carries two
218    /// numbers that belong to other people as much as to its owner:
219    /// `position` is a dense per-product join order that is deliberately
220    /// never recomputed, and `referrals` is a credit already granted. Delete
221    /// the row and the queue acquires a hole nobody can explain and every
222    /// `referred_by` pointing at its code orphans; keep it and null the
223    /// person out of it, and the request is answered without rewriting
224    /// anybody else's place in the line. Migration `0005` exists for exactly
225    /// this: `email` and `email_normalized` were `NOT NULL`, so the `SET … =
226    /// NULL` this declaration promises could not have run.
227    ///
228    /// `referral_code` is deliberately **kept**. It is a random 8-character
229    /// string that names nobody once the address is gone, and it is what the
230    /// referrals other people were credited for still resolve against.
231    ///
232    /// **Why `id` and not the address.** `GET /v1/privacy/export?subject=…`
233    /// puts the subject value in a URL, and URLs outlive their request in
234    /// access logs, proxies and browser history — which is why this module's
235    /// own links carry the opaque row id and never the address (issue #135).
236    /// Declaring `email_normalized` as the subject would have made a subject
237    /// access request the one operation that writes an address into those
238    /// logs.
239    ///
240    /// **Why the other two are `none`.** `waitlist_position_lock` is one row
241    /// per product and genuinely names nobody. `waitlist_send_cooldown` is
242    /// the harder case and its reason says so plainly: its primary key is the
243    /// address and product the mail throttle counts against, so the address
244    /// is inside a key rather than in a column of its own, and
245    /// `… WHERE <column> = ?` cannot reach it. That is the same shape issue
246    /// #266 describes for `Outbox`, and the table is core's
247    /// [`SendCooldown`](cratefield_core::SendCooldown) shape rather than this
248    /// module's, so giving it a column of its own is a change to every module
249    /// that has one. Saying so is the honest answer available here; inventing
250    /// a subject column that matched nothing would not be.
251    fn personal_data(&self) -> &'static [PersonalDataSet] {
252        const SETS: &[PersonalDataSet] = &[
253            PersonalDataSet {
254                table: "waitlist_entries",
255                subject: "id",
256                kind: DataKind::Contact,
257                disposition: Disposition::Anonymise(&["email", "email_normalized", "answers"]),
258                description: "Your place on a waitlist: the address you joined with, which \
259                              product, when you joined and confirmed, your number in the queue, \
260                              your referral code and how many people joined through it, and any \
261                              answers you gave on the way in.",
262                redacted: &[],
263                subject_via: None,
264            },
265            // Not `PersonalDataSet::none`: the row does hold the address, and
266            // bucketing it under "not personal" would tell the subject a
267            // table with their address in it holds nothing about anybody
268            // (issue #274). Declaring the `subject` column is not an option
269            // either: it holds `<email>:<product>`, so `WHERE subject = ?`
270            // with a bare address never matches — the value's *format* is
271            // the problem, not a missing hop, and widening `subject_via` to
272            // a prefix match would be a different mechanism. Core's
273            // `SendCooldown` shape is the change that closes it properly.
274            PersonalDataSet::unreachable(
275                "waitlist_send_cooldown",
276                DataKind::Contact,
277                "When we last emailed you about a waitlist, so the same address is not \
278                 mailed again within the hour.",
279                "The row is keyed on your address and the product together, so an erasure \
280                 request cannot match it with an address alone. Nothing else about you is \
281                 in the row, and it stops mattering once the queue does.",
282            ),
283            PersonalDataSet::none(
284                "waitlist_position_lock",
285                "One row per product, holding the lock that stops two people confirming in the \
286                 same instant from being given the same number in the queue. A product name and \
287                 a timestamp; nobody is named in it.",
288            ),
289        ];
290        SETS
291    }
292
293    fn emits(&self) -> &'static [&'static str] {
294        &[handlers::EVENT_JOINED, handlers::EVENT_CONFIRMED]
295    }
296
297    fn public_writes(&self) -> bool {
298        true
299    }
300
301    fn migrations(&self) -> Migrations {
302        const MIGRATIONS: [SqlMigration; 6] = [
303            MIGRATION_INIT,
304            MIGRATION_ENTRY_GENERATION,
305            MIGRATION_MAIL_COOLDOWN,
306            MIGRATION_POSITION_LOCK,
307            MIGRATION_ANONYMISABLE_ENTRY,
308            MIGRATION_POSITION_COUNTER,
309        ];
310        // The array is the apply order; this refuses a gap, a duplicate
311        // or an entry out of order at build time (issue #27).
312        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
313        Migrations {
314            sqlite: &MIGRATIONS,
315            postgres: &[],
316        }
317    }
318
319    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
320        let module = ModuleConfig::new("waitlist", cfg);
321        let mut errors = ConfigError::default();
322
323        if let Some(raw) = cfg
324            .get(&module.key("CONFIRM_TTL_DAYS"))
325            .map(|raw| raw.parse::<u32>())
326        {
327            let invalid = match raw {
328                Ok(days) => days == 0,
329                Err(_) => true,
330            };
331            if invalid {
332                errors.push(format!(
333                    "waitlist: {} must be a positive integer",
334                    module.key("CONFIRM_TTL_DAYS")
335                ));
336            }
337        }
338        for key in ["STATUS_REDIRECT", "EXPIRED_REDIRECT", "API_BASE"] {
339            if let Some(raw) = cfg.get(&module.key(key))
340                && !(raw.starts_with("https://") || raw.starts_with("http://"))
341            {
342                errors.push(format!(
343                    "waitlist: {} must be an absolute http(s) URL, got {raw:?}",
344                    module.key(key)
345                ));
346            }
347        }
348        if let Some(list) = cfg.get(&module.key("PRODUCTS"))
349            && list != "*"
350            && list
351                .split(',')
352                .map(str::trim)
353                .all(|slug| !is_product_slug(slug))
354        {
355            errors.push(format!(
356                "waitlist: {} must be a comma-separated list of kebab-case slugs or `*`",
357                module.key("PRODUCTS")
358            ));
359        }
360
361        errors.into_result()
362    }
363
364    fn router(&self, ctx: ModuleContext) -> axum::Router {
365        handlers::router(Arc::new(ctx), self.settings.clone())
366    }
367
368    fn surface(&self) -> cratefield_core::Surface {
369        handlers::surface(&self.settings)
370    }
371
372    fn scheduled<'a>(
373        &'a self,
374        ctx: &'a ModuleContext,
375        cron: &'a str,
376    ) -> BoxFuture<'a, Result<(), AnyError>> {
377        Box::pin(async move {
378            let Some(db) = ctx.ports.db.clone() else {
379                return Ok(());
380            };
381            let cfg = ModuleConfig::new("waitlist", &*ctx.config);
382            let days = i64::from(cfg.get_u32(
383                "RETENTION_DAYS_PENDING",
384                self.settings.retention_days_pending,
385            ));
386            let cutoff = handlers::iso_ago(days.saturating_mul(86_400));
387            let deleted = store::purge_pending_older_than(&*db, &cutoff)
388                .await
389                .map_err(|err| Box::new(err) as AnyError)?;
390            if deleted > 0 {
391                tracing::info!(deleted, cron, "purged stale pending waitlist entries");
392            }
393            // Claims whose window has closed are dead rows (issue #133).
394            let claims = cratefield_core::SendCooldown::new(handlers::SEND_COOLDOWN_TABLE)
395                .prune(
396                    &*db,
397                    &handlers::iso_ago(handlers::REMAIL_AFTER_SECS.saturating_mul(2)),
398                )
399                .await
400                .map_err(|err| Box::new(err) as AnyError)?;
401            if claims > 0 {
402                tracing::info!(claims, cron, "pruned expired waitlist send claims");
403            }
404            Ok(())
405        })
406    }
407}
408
409fn is_product_slug(slug: &str) -> bool {
410    !slug.is_empty()
411        && slug
412            .chars()
413            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn defaults_match_the_issue() {
422        let module = Waitlist::new();
423        assert_eq!(module.settings.confirm_ttl_days, 7);
424        assert_eq!(module.settings.retention_days_pending, 30);
425        assert!(module.settings.referrals);
426        assert_eq!(module.name(), "waitlist");
427        assert_eq!(
428            module.tables(),
429            [
430                "waitlist_entries",
431                "waitlist_send_cooldown",
432                "waitlist_position_lock"
433            ]
434        );
435        assert!(module.public_writes());
436        assert_eq!(module.requires(), [Port::Db, Port::Mailer, Port::Signer]);
437        assert_eq!(module.optional(), [Port::Captcha, Port::RateLimiter]);
438        assert_eq!(module.emits(), ["waitlist.joined", "waitlist.confirmed"]);
439    }
440
441    #[test]
442    fn referral_codes_are_8_char_crockford() {
443        for _ in 0..64 {
444            let code = handlers::referral_code();
445            assert_eq!(code.len(), 8);
446            assert!(
447                code.chars()
448                    .all(|c| c.is_ascii_digit() || c.is_ascii_lowercase()),
449                "{code}"
450            );
451        }
452    }
453}