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};
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 answers_schema: accept_any_answers(),
127 },
128 }
129 }
130
131 #[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 #[must_use]
141 pub fn any_product(mut self) -> Self {
142 self.settings.products = Products::Any;
143 self
144 }
145
146 #[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 #[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 #[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 #[must_use]
172 pub fn referrals(mut self, enabled: bool) -> Self {
173 self.settings.referrals = enabled;
174 self
175 }
176
177 #[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 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 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 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 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}