cratefield_module_waitlist/
lib.rs1#![doc = include_str!("../README.md")]
31#![forbid(unsafe_code)]
32
33mod handlers;
34mod mail;
35mod store;
36
37pub use mail::{ConfirmMailData, ConfirmedMailData, default_templates, themed_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
48pub type AnswersSchema = Arc<dyn Fn(&Value) -> Result<(), String> + Send + Sync>;
51
52fn accept_any_answers() -> AnswersSchema {
53 Arc::new(|_answers| Ok(()))
54}
55
56const 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
71const MIGRATION_MAIL_COOLDOWN: SqlMigration = SqlMigration::new(
74 "0003",
75 "mail_cooldown",
76 include_str!("../migrations/sqlite/0003_mail_cooldown.sql"),
77);
78
79const MIGRATION_POSITION_LOCK: SqlMigration = SqlMigration::new(
82 "0004",
83 "position_lock",
84 include_str!("../migrations/sqlite/0004_position_lock.sql"),
85);
86
87const MIGRATION_ANONYMISABLE_ENTRY: SqlMigration = SqlMigration::new(
91 "0005",
92 "anonymisable_entry",
93 include_str!("../migrations/sqlite/0005_anonymisable_entry.sql"),
94);
95
96const MIGRATION_POSITION_COUNTER: SqlMigration = SqlMigration::new(
98 "0006",
99 "position_counter",
100 include_str!("../migrations/sqlite/0006_position_counter.sql"),
101);
102
103pub struct Waitlist {
105 settings: Settings,
106}
107
108impl Default for Waitlist {
109 fn default() -> Self {
110 Self::new()
111 }
112}
113
114impl Waitlist {
115 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 #[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 #[must_use]
142 pub fn any_product(mut self) -> Self {
143 self.settings.products = Products::Any;
144 self
145 }
146
147 #[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 #[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 #[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 #[must_use]
173 pub fn referrals(mut self, enabled: bool) -> Self {
174 self.settings.referrals = enabled;
175 self
176 }
177
178 #[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 #[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 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 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 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 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}