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
103const MIGRATION_LOCK_BACKFILL: SqlMigration = SqlMigration::new(
106 "0007",
107 "lock_backfill",
108 include_str!("../migrations/sqlite/0007_lock_backfill.sql"),
109);
110
111pub struct Waitlist {
113 settings: Settings,
114}
115
116impl Default for Waitlist {
117 fn default() -> Self {
118 Self::new()
119 }
120}
121
122impl Waitlist {
123 pub fn new() -> Self {
127 Self {
128 settings: Settings {
129 products: Products::List(Vec::new()),
130 confirm_ttl_days: 7,
131 retention_days_pending: 30,
132 status_redirect: None,
133 referrals: true,
134 require_answers: false,
135 answers_schema: accept_any_answers(),
136 },
137 }
138 }
139
140 #[must_use]
142 pub fn products(mut self, products: impl IntoIterator<Item = impl Into<String>>) -> Self {
143 self.settings.products = Products::List(products.into_iter().map(Into::into).collect());
144 self
145 }
146
147 #[must_use]
150 pub fn any_product(mut self) -> Self {
151 self.settings.products = Products::Any;
152 self
153 }
154
155 #[must_use]
157 pub fn confirm_ttl_days(mut self, days: u32) -> Self {
158 self.settings.confirm_ttl_days = days;
159 self
160 }
161
162 #[must_use]
165 pub fn retention_days_pending(mut self, days: u32) -> Self {
166 self.settings.retention_days_pending = days;
167 self
168 }
169
170 #[must_use]
174 pub fn status_redirect(mut self, url: impl Into<String>) -> Self {
175 self.settings.status_redirect = Some(url.into());
176 self
177 }
178
179 #[must_use]
181 pub fn referrals(mut self, enabled: bool) -> Self {
182 self.settings.referrals = enabled;
183 self
184 }
185
186 #[must_use]
190 pub fn answers_schema(
191 mut self,
192 validate: impl Fn(&Value) -> Result<(), String> + Send + Sync + 'static,
193 ) -> Self {
194 self.settings.answers_schema = Arc::new(validate);
195 self
196 }
197
198 #[must_use]
202 pub fn require_answers(mut self, required: bool) -> Self {
203 self.settings.require_answers = required;
204 self
205 }
206}
207
208impl Module for Waitlist {
209 fn name(&self) -> &'static str {
210 "waitlist"
211 }
212
213 fn version(&self) -> &'static str {
214 env!("CARGO_PKG_VERSION")
215 }
216
217 fn requires(&self) -> &'static [Port] {
218 &[Port::Db, Port::Mailer, Port::Signer]
219 }
220
221 fn optional(&self) -> &'static [Port] {
222 &[Port::Captcha, Port::RateLimiter]
223 }
224
225 fn tables(&self) -> &'static [&'static str] {
226 &[
227 "waitlist_entries",
228 "waitlist_send_cooldown",
229 "waitlist_position_lock",
230 ]
231 }
232
233 fn personal_data(&self) -> &'static [PersonalDataSet] {
272 const SETS: &[PersonalDataSet] = &[
273 PersonalDataSet {
274 table: "waitlist_entries",
275 subject: "id",
276 kind: DataKind::Contact,
277 disposition: Disposition::Anonymise(&["email", "email_normalized", "answers"]),
278 description: "Your place on a waitlist: the address you joined with, which \
279 product, when you joined and confirmed, your number in the queue, \
280 your referral code and how many people joined through it, and any \
281 answers you gave on the way in.",
282 redacted: &[],
283 subject_via: None,
284 },
285 PersonalDataSet::unreachable(
295 "waitlist_send_cooldown",
296 DataKind::Contact,
297 "When we last emailed you about a waitlist, so the same address is not \
298 mailed again within the hour.",
299 "The row is keyed on your address and the product together, so an erasure \
300 request cannot match it with an address alone. Nothing else about you is \
301 in the row, and it stops mattering once the queue does.",
302 ),
303 PersonalDataSet::none(
304 "waitlist_position_lock",
305 "One row per product, holding the lock that stops two people confirming in the \
306 same instant from being given the same number in the queue. A product name and \
307 a timestamp; nobody is named in it.",
308 ),
309 ];
310 SETS
311 }
312
313 fn emits(&self) -> &'static [&'static str] {
314 &[handlers::EVENT_JOINED, handlers::EVENT_CONFIRMED]
315 }
316
317 fn public_writes(&self) -> bool {
318 true
319 }
320
321 fn migrations(&self) -> Migrations {
322 const MIGRATIONS: [SqlMigration; 7] = [
323 MIGRATION_INIT,
324 MIGRATION_ENTRY_GENERATION,
325 MIGRATION_MAIL_COOLDOWN,
326 MIGRATION_POSITION_LOCK,
327 MIGRATION_ANONYMISABLE_ENTRY,
328 MIGRATION_POSITION_COUNTER,
329 MIGRATION_LOCK_BACKFILL,
330 ];
331 const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
334 Migrations {
335 sqlite: &MIGRATIONS,
336 postgres: &[],
337 }
338 }
339
340 fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
341 let module = ModuleConfig::new("waitlist", cfg);
342 let mut errors = ConfigError::default();
343
344 if let Some(raw) = cfg
345 .get(&module.key("CONFIRM_TTL_DAYS"))
346 .map(|raw| raw.parse::<u32>())
347 {
348 let invalid = match raw {
349 Ok(days) => days == 0,
350 Err(_) => true,
351 };
352 if invalid {
353 errors.push(format!(
354 "waitlist: {} must be a positive integer",
355 module.key("CONFIRM_TTL_DAYS")
356 ));
357 }
358 }
359 for key in ["STATUS_REDIRECT", "EXPIRED_REDIRECT", "API_BASE"] {
360 if let Some(raw) = cfg.get(&module.key(key))
361 && !(raw.starts_with("https://") || raw.starts_with("http://"))
362 {
363 errors.push(format!(
364 "waitlist: {} must be an absolute http(s) URL, got {raw:?}",
365 module.key(key)
366 ));
367 }
368 }
369 if let Some(list) = cfg.get(&module.key("PRODUCTS"))
370 && list != "*"
371 && list
372 .split(',')
373 .map(str::trim)
374 .all(|slug| !is_product_slug(slug))
375 {
376 errors.push(format!(
377 "waitlist: {} must be a comma-separated list of kebab-case slugs or `*`",
378 module.key("PRODUCTS")
379 ));
380 }
381
382 errors.into_result()
383 }
384
385 fn router(&self, ctx: ModuleContext) -> axum::Router {
386 handlers::router(Arc::new(ctx), self.settings.clone())
387 }
388
389 fn surface(&self) -> cratefield_core::Surface {
390 handlers::surface(&self.settings)
391 }
392
393 fn scheduled<'a>(
394 &'a self,
395 ctx: &'a ModuleContext,
396 cron: &'a str,
397 ) -> BoxFuture<'a, Result<(), AnyError>> {
398 Box::pin(async move {
399 let Some(db) = ctx.ports.db.clone() else {
400 return Ok(());
401 };
402 let cfg = ModuleConfig::new("waitlist", &*ctx.config);
403 let days = i64::from(cfg.get_u32(
404 "RETENTION_DAYS_PENDING",
405 self.settings.retention_days_pending,
406 ));
407 let cutoff = handlers::iso_ago(days.saturating_mul(86_400));
408 let deleted = store::purge_pending_older_than(&*db, &cutoff)
409 .await
410 .map_err(|err| Box::new(err) as AnyError)?;
411 if deleted > 0 {
412 tracing::info!(deleted, cron, "purged stale pending waitlist entries");
413 }
414 let claims = cratefield_core::SendCooldown::new(handlers::SEND_COOLDOWN_TABLE)
416 .prune(
417 &*db,
418 &handlers::iso_ago(handlers::REMAIL_AFTER_SECS.saturating_mul(2)),
419 )
420 .await
421 .map_err(|err| Box::new(err) as AnyError)?;
422 if claims > 0 {
423 tracing::info!(claims, cron, "pruned expired waitlist send claims");
424 }
425 Ok(())
426 })
427 }
428}
429
430fn is_product_slug(slug: &str) -> bool {
431 !slug.is_empty()
432 && slug
433 .chars()
434 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 #[test]
442 fn defaults_match_the_issue() {
443 let module = Waitlist::new();
444 assert_eq!(module.settings.confirm_ttl_days, 7);
445 assert_eq!(module.settings.retention_days_pending, 30);
446 assert!(module.settings.referrals);
447 assert_eq!(module.name(), "waitlist");
448 assert_eq!(
449 module.tables(),
450 [
451 "waitlist_entries",
452 "waitlist_send_cooldown",
453 "waitlist_position_lock"
454 ]
455 );
456 assert!(module.public_writes());
457 assert_eq!(module.requires(), [Port::Db, Port::Mailer, Port::Signer]);
458 assert_eq!(module.optional(), [Port::Captcha, Port::RateLimiter]);
459 assert_eq!(module.emits(), ["waitlist.joined", "waitlist.confirmed"]);
460 }
461
462 #[test]
463 fn referral_codes_are_8_char_crockford() {
464 for _ in 0..64 {
465 let code = handlers::referral_code();
466 assert_eq!(code.len(), 8);
467 assert!(
468 code.chars()
469 .all(|c| c.is_ascii_digit() || c.is_ascii_lowercase()),
470 "{code}"
471 );
472 }
473 }
474}