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 runs `1 + MAX(position)` for
15//! the product *inside* the position UPDATE, and that UPDATE plus the
16//! referrer credit run in one [`cratefield_core::Database::batch`] —
17//! atomic on D1, a transaction on the sqlite adapter — so concurrent
18//! confirmations never share a position. Positions are dense at assign
19//! time per product and never recomputed on delete.
20//!
21//! **No enumeration** (section 11): `POST /v1/waitlist` answers the same
22//! `202 {"ok":true}` bytes whatever the row state; an unknown `ref` code
23//! is silently ignored.
24
25#![doc = include_str!("../README.md")]
26#![forbid(unsafe_code)]
27
28mod handlers;
29mod mail;
30mod store;
31
32pub use mail::{ConfirmMailData, ConfirmedMailData, default_templates};
33
34use cratefield_core::{
35    AnyError, BoxFuture, Config, ConfigError, Migrations, Module, ModuleConfig, ModuleContext,
36    Port, SqlMigration,
37};
38use serde_json::Value;
39use std::sync::Arc;
40
41use handlers::{Products, Settings};
42
43/// A venture's validation for the free-form `answers` field of a join.
44/// `Err(detail)` becomes a `400 validation-failed` problem.
45pub type AnswersSchema = Arc<dyn Fn(&Value) -> Result<(), String> + Send + Sync>;
46
47fn accept_any_answers() -> AnswersSchema {
48    Arc::new(|_answers| Ok(()))
49}
50
51/// The module's one migration: the `waitlist_entries` table in the
52/// portable SQL subset (issue #11).
53const MIGRATION_INIT: SqlMigration = SqlMigration {
54    id: "0001",
55    name: "init",
56    sql: include_str!("../migrations/sqlite/0001_init.sql"),
57};
58
59/// A per-product waitlist.
60pub struct Waitlist {
61    settings: Settings,
62}
63
64impl Default for Waitlist {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl Waitlist {
71    /// No products allowed until [`Waitlist::products`] or
72    /// [`Waitlist::any_product`] says otherwise; 7-day confirm TTL;
73    /// referrals on.
74    pub fn new() -> Self {
75        Self {
76            settings: Settings {
77                products: Products::List(Vec::new()),
78                confirm_ttl_days: 7,
79                retention_days_pending: 30,
80                status_redirect: None,
81                referrals: true,
82                answers_schema: accept_any_answers(),
83            },
84        }
85    }
86
87    /// Exactly these product slugs may be joined.
88    #[must_use]
89    pub fn products(mut self, products: impl IntoIterator<Item = impl Into<String>>) -> Self {
90        self.settings.products = Products::List(products.into_iter().map(Into::into).collect());
91        self
92    }
93
94    /// Accept any product slug (single-product ventures that do not want
95    /// an allowlist).
96    #[must_use]
97    pub fn any_product(mut self) -> Self {
98        self.settings.products = Products::Any;
99        self
100    }
101
102    /// How long a confirmation link stays valid (default 7 days).
103    #[must_use]
104    pub fn confirm_ttl_days(mut self, days: u32) -> Self {
105        self.settings.confirm_ttl_days = days;
106        self
107    }
108
109    /// Purge `pending` entries untouched for this many days from the
110    /// scheduled handler (default 30; issue #13 retention rule).
111    #[must_use]
112    pub fn retention_days_pending(mut self, days: u32) -> Self {
113        self.settings.retention_days_pending = days;
114        self
115    }
116
117    /// Where confirmation redirects to, as
118    /// `<url>?token=<status token>` (default
119    /// `<public_url>/waitlist/status`).
120    #[must_use]
121    pub fn status_redirect(mut self, url: impl Into<String>) -> Self {
122        self.settings.status_redirect = Some(url.into());
123        self
124    }
125
126    /// Whether `ref` codes credit referrers (default `true`).
127    #[must_use]
128    pub fn referrals(mut self, enabled: bool) -> Self {
129        self.settings.referrals = enabled;
130        self
131    }
132
133    /// Validates the free-form `answers` JSON of a join (default: accept
134    /// anything).
135    #[must_use]
136    pub fn answers_schema(
137        mut self,
138        validate: impl Fn(&Value) -> Result<(), String> + Send + Sync + 'static,
139    ) -> Self {
140        self.settings.answers_schema = Arc::new(validate);
141        self
142    }
143}
144
145impl Module for Waitlist {
146    fn name(&self) -> &'static str {
147        "waitlist"
148    }
149
150    fn version(&self) -> &'static str {
151        env!("CARGO_PKG_VERSION")
152    }
153
154    fn requires(&self) -> &'static [Port] {
155        &[Port::Db, Port::Mailer, Port::Signer]
156    }
157
158    fn optional(&self) -> &'static [Port] {
159        &[Port::Captcha, Port::RateLimiter]
160    }
161
162    fn tables(&self) -> &'static [&'static str] {
163        &["waitlist_entries"]
164    }
165
166    fn emits(&self) -> &'static [&'static str] {
167        &[handlers::EVENT_JOINED, handlers::EVENT_CONFIRMED]
168    }
169
170    fn public_writes(&self) -> bool {
171        true
172    }
173
174    fn migrations(&self) -> Migrations {
175        const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
176        Migrations {
177            sqlite: &MIGRATIONS,
178            postgres: &[],
179        }
180    }
181
182    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
183        let module = ModuleConfig::new("waitlist", cfg);
184        let mut errors = ConfigError::default();
185
186        if let Some(raw) = cfg
187            .get(&module.key("CONFIRM_TTL_DAYS"))
188            .map(|raw| raw.parse::<u32>())
189        {
190            let invalid = match raw {
191                Ok(days) => days == 0,
192                Err(_) => true,
193            };
194            if invalid {
195                errors.push(format!(
196                    "waitlist: {} must be a positive integer",
197                    module.key("CONFIRM_TTL_DAYS")
198                ));
199            }
200        }
201        for key in ["STATUS_REDIRECT", "EXPIRED_REDIRECT", "API_BASE"] {
202            if let Some(raw) = cfg.get(&module.key(key))
203                && !(raw.starts_with("https://") || raw.starts_with("http://"))
204            {
205                errors.push(format!(
206                    "waitlist: {} must be an absolute http(s) URL, got {raw:?}",
207                    module.key(key)
208                ));
209            }
210        }
211        if let Some(list) = cfg.get(&module.key("PRODUCTS"))
212            && list != "*"
213            && list
214                .split(',')
215                .map(str::trim)
216                .all(|slug| !is_product_slug(slug))
217        {
218            errors.push(format!(
219                "waitlist: {} must be a comma-separated list of kebab-case slugs or `*`",
220                module.key("PRODUCTS")
221            ));
222        }
223
224        errors.into_result()
225    }
226
227    fn router(&self, ctx: ModuleContext) -> axum::Router {
228        handlers::router(Arc::new(ctx), self.settings.clone())
229    }
230
231    fn surface(&self) -> cratefield_core::Surface {
232        handlers::surface(&self.settings)
233    }
234
235    fn scheduled<'a>(
236        &'a self,
237        ctx: &'a ModuleContext,
238        cron: &'a str,
239    ) -> BoxFuture<'a, Result<(), AnyError>> {
240        Box::pin(async move {
241            let Some(db) = ctx.ports.db.clone() else {
242                return Ok(());
243            };
244            let cfg = ModuleConfig::new("waitlist", &*ctx.config);
245            let days = i64::from(cfg.get_u32(
246                "RETENTION_DAYS_PENDING",
247                self.settings.retention_days_pending,
248            ));
249            let cutoff = handlers::iso_ago(days.saturating_mul(86_400));
250            let deleted = store::purge_pending_older_than(&*db, &cutoff)
251                .await
252                .map_err(|err| Box::new(err) as AnyError)?;
253            if deleted > 0 {
254                tracing::info!(deleted, cron, "purged stale pending waitlist entries");
255            }
256            Ok(())
257        })
258    }
259}
260
261fn is_product_slug(slug: &str) -> bool {
262    !slug.is_empty()
263        && slug
264            .chars()
265            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn defaults_match_the_issue() {
274        let module = Waitlist::new();
275        assert_eq!(module.settings.confirm_ttl_days, 7);
276        assert_eq!(module.settings.retention_days_pending, 30);
277        assert!(module.settings.referrals);
278        assert_eq!(module.name(), "waitlist");
279        assert_eq!(module.tables(), ["waitlist_entries"]);
280        assert!(module.public_writes());
281        assert_eq!(module.requires(), [Port::Db, Port::Mailer, Port::Signer]);
282        assert_eq!(module.optional(), [Port::Captcha, Port::RateLimiter]);
283        assert_eq!(module.emits(), ["waitlist.joined", "waitlist.confirmed"]);
284    }
285
286    #[test]
287    fn referral_codes_are_8_char_crockford() {
288        for _ in 0..64 {
289            let code = handlers::referral_code();
290            assert_eq!(code.len(), 8);
291            assert!(
292                code.chars()
293                    .all(|c| c.is_ascii_digit() || c.is_ascii_lowercase()),
294                "{code}"
295            );
296        }
297    }
298}