#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
mod handlers;
mod mail;
mod store;
pub use mail::{ConfirmMailData, ConfirmedMailData, default_templates, themed_templates};
use cratefield_core::{
AnyError, BoxFuture, Config, ConfigError, DataKind, Disposition, Migrations, Module,
ModuleConfig, ModuleContext, PersonalDataSet, Port, SqlMigration,
};
use serde_json::Value;
use std::sync::Arc;
use handlers::{Products, Settings};
pub type AnswersSchema = Arc<dyn Fn(&Value) -> Result<(), String> + Send + Sync>;
fn accept_any_answers() -> AnswersSchema {
Arc::new(|_answers| Ok(()))
}
const MIGRATION_INIT: SqlMigration = SqlMigration::new(
"0001",
"init",
include_str!("../migrations/sqlite/0001_init.sql"),
);
const MIGRATION_ENTRY_GENERATION: SqlMigration = SqlMigration::new(
"0002",
"entry_generation",
include_str!("../migrations/sqlite/0002_entry_generation.sql"),
);
const MIGRATION_MAIL_COOLDOWN: SqlMigration = SqlMigration::new(
"0003",
"mail_cooldown",
include_str!("../migrations/sqlite/0003_mail_cooldown.sql"),
);
const MIGRATION_POSITION_LOCK: SqlMigration = SqlMigration::new(
"0004",
"position_lock",
include_str!("../migrations/sqlite/0004_position_lock.sql"),
);
const MIGRATION_ANONYMISABLE_ENTRY: SqlMigration = SqlMigration::new(
"0005",
"anonymisable_entry",
include_str!("../migrations/sqlite/0005_anonymisable_entry.sql"),
);
const MIGRATION_POSITION_COUNTER: SqlMigration = SqlMigration::new(
"0006",
"position_counter",
include_str!("../migrations/sqlite/0006_position_counter.sql"),
);
pub struct Waitlist {
settings: Settings,
}
impl Default for Waitlist {
fn default() -> Self {
Self::new()
}
}
impl Waitlist {
pub fn new() -> Self {
Self {
settings: Settings {
products: Products::List(Vec::new()),
confirm_ttl_days: 7,
retention_days_pending: 30,
status_redirect: None,
referrals: true,
require_answers: false,
answers_schema: accept_any_answers(),
},
}
}
#[must_use]
pub fn products(mut self, products: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.settings.products = Products::List(products.into_iter().map(Into::into).collect());
self
}
#[must_use]
pub fn any_product(mut self) -> Self {
self.settings.products = Products::Any;
self
}
#[must_use]
pub fn confirm_ttl_days(mut self, days: u32) -> Self {
self.settings.confirm_ttl_days = days;
self
}
#[must_use]
pub fn retention_days_pending(mut self, days: u32) -> Self {
self.settings.retention_days_pending = days;
self
}
#[must_use]
pub fn status_redirect(mut self, url: impl Into<String>) -> Self {
self.settings.status_redirect = Some(url.into());
self
}
#[must_use]
pub fn referrals(mut self, enabled: bool) -> Self {
self.settings.referrals = enabled;
self
}
#[must_use]
pub fn answers_schema(
mut self,
validate: impl Fn(&Value) -> Result<(), String> + Send + Sync + 'static,
) -> Self {
self.settings.answers_schema = Arc::new(validate);
self
}
#[must_use]
pub fn require_answers(mut self, required: bool) -> Self {
self.settings.require_answers = required;
self
}
}
impl Module for Waitlist {
fn name(&self) -> &'static str {
"waitlist"
}
fn version(&self) -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn requires(&self) -> &'static [Port] {
&[Port::Db, Port::Mailer, Port::Signer]
}
fn optional(&self) -> &'static [Port] {
&[Port::Captcha, Port::RateLimiter]
}
fn tables(&self) -> &'static [&'static str] {
&[
"waitlist_entries",
"waitlist_send_cooldown",
"waitlist_position_lock",
]
}
fn personal_data(&self) -> &'static [PersonalDataSet] {
const SETS: &[PersonalDataSet] = &[
PersonalDataSet {
table: "waitlist_entries",
subject: "id",
kind: DataKind::Contact,
disposition: Disposition::Anonymise(&["email", "email_normalized", "answers"]),
description: "Your place on a waitlist: the address you joined with, which \
product, when you joined and confirmed, your number in the queue, \
your referral code and how many people joined through it, and any \
answers you gave on the way in.",
redacted: &[],
subject_via: None,
},
PersonalDataSet::unreachable(
"waitlist_send_cooldown",
DataKind::Contact,
"When we last emailed you about a waitlist, so the same address is not \
mailed again within the hour.",
"The row is keyed on your address and the product together, so an erasure \
request cannot match it with an address alone. Nothing else about you is \
in the row, and it stops mattering once the queue does.",
),
PersonalDataSet::none(
"waitlist_position_lock",
"One row per product, holding the lock that stops two people confirming in the \
same instant from being given the same number in the queue. A product name and \
a timestamp; nobody is named in it.",
),
];
SETS
}
fn emits(&self) -> &'static [&'static str] {
&[handlers::EVENT_JOINED, handlers::EVENT_CONFIRMED]
}
fn public_writes(&self) -> bool {
true
}
fn migrations(&self) -> Migrations {
const MIGRATIONS: [SqlMigration; 6] = [
MIGRATION_INIT,
MIGRATION_ENTRY_GENERATION,
MIGRATION_MAIL_COOLDOWN,
MIGRATION_POSITION_LOCK,
MIGRATION_ANONYMISABLE_ENTRY,
MIGRATION_POSITION_COUNTER,
];
const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
Migrations {
sqlite: &MIGRATIONS,
postgres: &[],
}
}
fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
let module = ModuleConfig::new("waitlist", cfg);
let mut errors = ConfigError::default();
if let Some(raw) = cfg
.get(&module.key("CONFIRM_TTL_DAYS"))
.map(|raw| raw.parse::<u32>())
{
let invalid = match raw {
Ok(days) => days == 0,
Err(_) => true,
};
if invalid {
errors.push(format!(
"waitlist: {} must be a positive integer",
module.key("CONFIRM_TTL_DAYS")
));
}
}
for key in ["STATUS_REDIRECT", "EXPIRED_REDIRECT", "API_BASE"] {
if let Some(raw) = cfg.get(&module.key(key))
&& !(raw.starts_with("https://") || raw.starts_with("http://"))
{
errors.push(format!(
"waitlist: {} must be an absolute http(s) URL, got {raw:?}",
module.key(key)
));
}
}
if let Some(list) = cfg.get(&module.key("PRODUCTS"))
&& list != "*"
&& list
.split(',')
.map(str::trim)
.all(|slug| !is_product_slug(slug))
{
errors.push(format!(
"waitlist: {} must be a comma-separated list of kebab-case slugs or `*`",
module.key("PRODUCTS")
));
}
errors.into_result()
}
fn router(&self, ctx: ModuleContext) -> axum::Router {
handlers::router(Arc::new(ctx), self.settings.clone())
}
fn surface(&self) -> cratefield_core::Surface {
handlers::surface(&self.settings)
}
fn scheduled<'a>(
&'a self,
ctx: &'a ModuleContext,
cron: &'a str,
) -> BoxFuture<'a, Result<(), AnyError>> {
Box::pin(async move {
let Some(db) = ctx.ports.db.clone() else {
return Ok(());
};
let cfg = ModuleConfig::new("waitlist", &*ctx.config);
let days = i64::from(cfg.get_u32(
"RETENTION_DAYS_PENDING",
self.settings.retention_days_pending,
));
let cutoff = handlers::iso_ago(days.saturating_mul(86_400));
let deleted = store::purge_pending_older_than(&*db, &cutoff)
.await
.map_err(|err| Box::new(err) as AnyError)?;
if deleted > 0 {
tracing::info!(deleted, cron, "purged stale pending waitlist entries");
}
let claims = cratefield_core::SendCooldown::new(handlers::SEND_COOLDOWN_TABLE)
.prune(
&*db,
&handlers::iso_ago(handlers::REMAIL_AFTER_SECS.saturating_mul(2)),
)
.await
.map_err(|err| Box::new(err) as AnyError)?;
if claims > 0 {
tracing::info!(claims, cron, "pruned expired waitlist send claims");
}
Ok(())
})
}
}
fn is_product_slug(slug: &str) -> bool {
!slug.is_empty()
&& slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_the_issue() {
let module = Waitlist::new();
assert_eq!(module.settings.confirm_ttl_days, 7);
assert_eq!(module.settings.retention_days_pending, 30);
assert!(module.settings.referrals);
assert_eq!(module.name(), "waitlist");
assert_eq!(
module.tables(),
[
"waitlist_entries",
"waitlist_send_cooldown",
"waitlist_position_lock"
]
);
assert!(module.public_writes());
assert_eq!(module.requires(), [Port::Db, Port::Mailer, Port::Signer]);
assert_eq!(module.optional(), [Port::Captcha, Port::RateLimiter]);
assert_eq!(module.emits(), ["waitlist.joined", "waitlist.confirmed"]);
}
#[test]
fn referral_codes_are_8_char_crockford() {
for _ in 0..64 {
let code = handlers::referral_code();
assert_eq!(code.len(), 8);
assert!(
code.chars()
.all(|c| c.is_ascii_digit() || c.is_ascii_lowercase()),
"{code}"
);
}
}
}