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                require_answers: false,
127                answers_schema: accept_any_answers(),
128            },
129        }
130    }
131
132    /// Exactly these product slugs may be joined.
133    #[must_use]
134    pub fn products(mut self, products: impl IntoIterator<Item = impl Into<String>>) -> Self {
135        self.settings.products = Products::List(products.into_iter().map(Into::into).collect());
136        self
137    }
138
139    /// Accept any product slug (single-product ventures that do not want
140    /// an allowlist).
141    #[must_use]
142    pub fn any_product(mut self) -> Self {
143        self.settings.products = Products::Any;
144        self
145    }
146
147    /// How long a confirmation link stays valid (default 7 days).
148    #[must_use]
149    pub fn confirm_ttl_days(mut self, days: u32) -> Self {
150        self.settings.confirm_ttl_days = days;
151        self
152    }
153
154    /// Purge `pending` entries untouched for this many days from the
155    /// scheduled handler (default 30; issue #13 retention rule).
156    #[must_use]
157    pub fn retention_days_pending(mut self, days: u32) -> Self {
158        self.settings.retention_days_pending = days;
159        self
160    }
161
162    /// Where confirmation redirects to, as
163    /// `<url>?token=<status token>` (default
164    /// `<public_url>/waitlist/status`).
165    #[must_use]
166    pub fn status_redirect(mut self, url: impl Into<String>) -> Self {
167        self.settings.status_redirect = Some(url.into());
168        self
169    }
170
171    /// Whether `ref` codes credit referrers (default `true`).
172    #[must_use]
173    pub fn referrals(mut self, enabled: bool) -> Self {
174        self.settings.referrals = enabled;
175        self
176    }
177
178    /// Validates the free-form `answers` JSON of a join (default: accept
179    /// anything). Only runs when the join carries answers; pair with
180    /// [`Self::require_answers`] to also reject joins without them.
181    #[must_use]
182    pub fn answers_schema(
183        mut self,
184        validate: impl Fn(&Value) -> Result<(), String> + Send + Sync + 'static,
185    ) -> Self {
186        self.settings.answers_schema = Arc::new(validate);
187        self
188    }
189
190    /// Whether a join must carry `answers` (default `false`): with
191    /// `true`, a body without the field — absent or `null` — is
192    /// rejected as `validation-failed`.
193    #[must_use]
194    pub fn require_answers(mut self, required: bool) -> Self {
195        self.settings.require_answers = required;
196        self
197    }
198}
199
200impl Module for Waitlist {
201    fn name(&self) -> &'static str {
202        "waitlist"
203    }
204
205    fn version(&self) -> &'static str {
206        env!("CARGO_PKG_VERSION")
207    }
208
209    fn requires(&self) -> &'static [Port] {
210        &[Port::Db, Port::Mailer, Port::Signer]
211    }
212
213    fn optional(&self) -> &'static [Port] {
214        &[Port::Captcha, Port::RateLimiter]
215    }
216
217    fn tables(&self) -> &'static [&'static str] {
218        &[
219            "waitlist_entries",
220            "waitlist_send_cooldown",
221            "waitlist_position_lock",
222        ]
223    }
224
225    /// The entry is anonymised, not deleted; the other two tables have no
226    /// subject column at all (issue #265).
227    ///
228    /// **Why `Anonymise` and not `Erase`.** A confirmed entry carries two
229    /// numbers that belong to other people as much as to its owner:
230    /// `position` is a dense per-product join order that is deliberately
231    /// never recomputed, and `referrals` is a credit already granted. Delete
232    /// the row and the queue acquires a hole nobody can explain and every
233    /// `referred_by` pointing at its code orphans; keep it and null the
234    /// person out of it, and the request is answered without rewriting
235    /// anybody else's place in the line. Migration `0005` exists for exactly
236    /// this: `email` and `email_normalized` were `NOT NULL`, so the `SET … =
237    /// NULL` this declaration promises could not have run.
238    ///
239    /// `referral_code` is deliberately **kept**. It is a random 8-character
240    /// string that names nobody once the address is gone, and it is what the
241    /// referrals other people were credited for still resolve against.
242    ///
243    /// **Why `id` and not the address.** `GET /v1/privacy/export?subject=…`
244    /// puts the subject value in a URL, and URLs outlive their request in
245    /// access logs, proxies and browser history — which is why this module's
246    /// own links carry the opaque row id and never the address (issue #135).
247    /// Declaring `email_normalized` as the subject would have made a subject
248    /// access request the one operation that writes an address into those
249    /// logs.
250    ///
251    /// **Why the other two have no subject column.** `waitlist_position_lock`
252    /// is one row per product, genuinely names nobody, and is `none`.
253    /// `waitlist_send_cooldown` is the harder case, declared `unreachable`,
254    /// and its reason says so plainly: its primary key is the
255    /// address and product the mail throttle counts against, so the address
256    /// is inside a key rather than in a column of its own, and
257    /// `… WHERE <column> = ?` cannot reach it. That is the same shape issue
258    /// #266 describes for `Outbox`, and the table is core's
259    /// [`SendCooldown`](cratefield_core::SendCooldown) shape rather than this
260    /// module's, so giving it a column of its own is a change to every module
261    /// that has one. Saying so is the honest answer available here; inventing
262    /// a subject column that matched nothing would not be.
263    fn personal_data(&self) -> &'static [PersonalDataSet] {
264        const SETS: &[PersonalDataSet] = &[
265            PersonalDataSet {
266                table: "waitlist_entries",
267                subject: "id",
268                kind: DataKind::Contact,
269                disposition: Disposition::Anonymise(&["email", "email_normalized", "answers"]),
270                description: "Your place on a waitlist: the address you joined with, which \
271                              product, when you joined and confirmed, your number in the queue, \
272                              your referral code and how many people joined through it, and any \
273                              answers you gave on the way in.",
274                redacted: &[],
275                subject_via: None,
276            },
277            // Not `PersonalDataSet::none`: the row does hold the address, and
278            // bucketing it under "not personal" would tell the subject a
279            // table with their address in it holds nothing about anybody
280            // (issue #274). Declaring the `subject` column is not an option
281            // either: it holds `<email>:<product>`, so `WHERE subject = ?`
282            // with a bare address never matches — the value's *format* is
283            // the problem, not a missing hop, and widening `subject_via` to
284            // a prefix match would be a different mechanism. Core's
285            // `SendCooldown` shape is the change that closes it properly.
286            PersonalDataSet::unreachable(
287                "waitlist_send_cooldown",
288                DataKind::Contact,
289                "When we last emailed you about a waitlist, so the same address is not \
290                 mailed again within the hour.",
291                "The row is keyed on your address and the product together, so an erasure \
292                 request cannot match it with an address alone. Nothing else about you is \
293                 in the row, and it stops mattering once the queue does.",
294            ),
295            PersonalDataSet::none(
296                "waitlist_position_lock",
297                "One row per product, holding the lock that stops two people confirming in the \
298                 same instant from being given the same number in the queue. A product name and \
299                 a timestamp; nobody is named in it.",
300            ),
301        ];
302        SETS
303    }
304
305    fn emits(&self) -> &'static [&'static str] {
306        &[handlers::EVENT_JOINED, handlers::EVENT_CONFIRMED]
307    }
308
309    fn public_writes(&self) -> bool {
310        true
311    }
312
313    fn migrations(&self) -> Migrations {
314        const MIGRATIONS: [SqlMigration; 6] = [
315            MIGRATION_INIT,
316            MIGRATION_ENTRY_GENERATION,
317            MIGRATION_MAIL_COOLDOWN,
318            MIGRATION_POSITION_LOCK,
319            MIGRATION_ANONYMISABLE_ENTRY,
320            MIGRATION_POSITION_COUNTER,
321        ];
322        // The array is the apply order; this refuses a gap, a duplicate
323        // or an entry out of order at build time (issue #27).
324        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
325        Migrations {
326            sqlite: &MIGRATIONS,
327            postgres: &[],
328        }
329    }
330
331    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
332        let module = ModuleConfig::new("waitlist", cfg);
333        let mut errors = ConfigError::default();
334
335        if let Some(raw) = cfg
336            .get(&module.key("CONFIRM_TTL_DAYS"))
337            .map(|raw| raw.parse::<u32>())
338        {
339            let invalid = match raw {
340                Ok(days) => days == 0,
341                Err(_) => true,
342            };
343            if invalid {
344                errors.push(format!(
345                    "waitlist: {} must be a positive integer",
346                    module.key("CONFIRM_TTL_DAYS")
347                ));
348            }
349        }
350        for key in ["STATUS_REDIRECT", "EXPIRED_REDIRECT", "API_BASE"] {
351            if let Some(raw) = cfg.get(&module.key(key))
352                && !(raw.starts_with("https://") || raw.starts_with("http://"))
353            {
354                errors.push(format!(
355                    "waitlist: {} must be an absolute http(s) URL, got {raw:?}",
356                    module.key(key)
357                ));
358            }
359        }
360        if let Some(list) = cfg.get(&module.key("PRODUCTS"))
361            && list != "*"
362            && list
363                .split(',')
364                .map(str::trim)
365                .all(|slug| !is_product_slug(slug))
366        {
367            errors.push(format!(
368                "waitlist: {} must be a comma-separated list of kebab-case slugs or `*`",
369                module.key("PRODUCTS")
370            ));
371        }
372
373        errors.into_result()
374    }
375
376    fn router(&self, ctx: ModuleContext) -> axum::Router {
377        handlers::router(Arc::new(ctx), self.settings.clone())
378    }
379
380    fn surface(&self) -> cratefield_core::Surface {
381        handlers::surface(&self.settings)
382    }
383
384    fn scheduled<'a>(
385        &'a self,
386        ctx: &'a ModuleContext,
387        cron: &'a str,
388    ) -> BoxFuture<'a, Result<(), AnyError>> {
389        Box::pin(async move {
390            let Some(db) = ctx.ports.db.clone() else {
391                return Ok(());
392            };
393            let cfg = ModuleConfig::new("waitlist", &*ctx.config);
394            let days = i64::from(cfg.get_u32(
395                "RETENTION_DAYS_PENDING",
396                self.settings.retention_days_pending,
397            ));
398            let cutoff = handlers::iso_ago(days.saturating_mul(86_400));
399            let deleted = store::purge_pending_older_than(&*db, &cutoff)
400                .await
401                .map_err(|err| Box::new(err) as AnyError)?;
402            if deleted > 0 {
403                tracing::info!(deleted, cron, "purged stale pending waitlist entries");
404            }
405            // Claims whose window has closed are dead rows (issue #133).
406            let claims = cratefield_core::SendCooldown::new(handlers::SEND_COOLDOWN_TABLE)
407                .prune(
408                    &*db,
409                    &handlers::iso_ago(handlers::REMAIL_AFTER_SECS.saturating_mul(2)),
410                )
411                .await
412                .map_err(|err| Box::new(err) as AnyError)?;
413            if claims > 0 {
414                tracing::info!(claims, cron, "pruned expired waitlist send claims");
415            }
416            Ok(())
417        })
418    }
419}
420
421fn is_product_slug(slug: &str) -> bool {
422    !slug.is_empty()
423        && slug
424            .chars()
425            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn defaults_match_the_issue() {
434        let module = Waitlist::new();
435        assert_eq!(module.settings.confirm_ttl_days, 7);
436        assert_eq!(module.settings.retention_days_pending, 30);
437        assert!(module.settings.referrals);
438        assert_eq!(module.name(), "waitlist");
439        assert_eq!(
440            module.tables(),
441            [
442                "waitlist_entries",
443                "waitlist_send_cooldown",
444                "waitlist_position_lock"
445            ]
446        );
447        assert!(module.public_writes());
448        assert_eq!(module.requires(), [Port::Db, Port::Mailer, Port::Signer]);
449        assert_eq!(module.optional(), [Port::Captcha, Port::RateLimiter]);
450        assert_eq!(module.emits(), ["waitlist.joined", "waitlist.confirmed"]);
451    }
452
453    #[test]
454    fn referral_codes_are_8_char_crockford() {
455        for _ in 0..64 {
456            let code = handlers::referral_code();
457            assert_eq!(code.len(), 8);
458            assert!(
459                code.chars()
460                    .all(|c| c.is_ascii_digit() || c.is_ascii_lowercase()),
461                "{code}"
462            );
463        }
464    }
465}