cratefield_module_waitlist/
lib.rs1#![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
43pub type AnswersSchema = Arc<dyn Fn(&Value) -> Result<(), String> + Send + Sync>;
46
47fn accept_any_answers() -> AnswersSchema {
48 Arc::new(|_answers| Ok(()))
49}
50
51const MIGRATION_INIT: SqlMigration = SqlMigration {
54 id: "0001",
55 name: "init",
56 sql: include_str!("../migrations/sqlite/0001_init.sql"),
57};
58
59pub struct Waitlist {
61 settings: Settings,
62}
63
64impl Default for Waitlist {
65 fn default() -> Self {
66 Self::new()
67 }
68}
69
70impl Waitlist {
71 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 #[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 #[must_use]
97 pub fn any_product(mut self) -> Self {
98 self.settings.products = Products::Any;
99 self
100 }
101
102 #[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 #[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 #[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 #[must_use]
128 pub fn referrals(mut self, enabled: bool) -> Self {
129 self.settings.referrals = enabled;
130 self
131 }
132
133 #[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}