//! What a user asked to be told about. //! //! These forward to `crate::db::lists::sync_notification_subscription` rather //! than writing `users` directly. Grouping them puts that boundary //! (migration 189) somewhere a reader can see it. use sqlx::PgPool; use crate::db::UserId; use crate::db::validated_types::Email; use crate::error::Result; /// Get all user emails for bulk notifications (e.g. shutdown notice). /// /// Capped to bound memory on a full-table scan; if the cap is ever hit the WARN /// fires so we know to switch to a paged dispatch model (mirrors /// [`get_status_alert_subscribers`]). #[tracing::instrument(skip_all)] pub async fn get_all_user_emails(pool: &PgPool) -> Result)>> { const ALL_EMAILS_CAP: i64 = 50_000; let rows = sqlx::query_as::<_, (String, Option)>( "SELECT email, display_name FROM users ORDER BY created_at ASC LIMIT $1", ) .bind(ALL_EMAILS_CAP) .fetch_all(pool) .await?; if rows.len() as i64 == ALL_EMAILS_CAP { tracing::warn!( cap = ALL_EMAILS_CAP, "get_all_user_emails hit its cap; some users omitted, switch to paged dispatch" ); } Ok(rows) } /// Update a user's email notification preferences. /// /// Writes subscriptions only. The `users.notify_*` columns these used to set /// are gone (migration 189); `db::lists` is the single record, and the /// preferences page and the unsubscribe links write the same rows. #[derive(Debug, Clone, Copy)] pub struct NotificationPreferences { pub notify_sale: bool, pub notify_follower: bool, pub notify_release: bool, pub login_notification_enabled: bool, pub notify_issues: bool, pub notify_status: bool, pub notify_invite: bool, } #[tracing::instrument(skip_all)] pub async fn update_notification_preferences( pool: &PgPool, id: UserId, prefs: NotificationPreferences, ) -> Result<()> { let NotificationPreferences { notify_sale, notify_follower, notify_release, login_notification_enabled, notify_issues, notify_status, notify_invite, } = prefs; for (kind, enabled) in [ ("sale", notify_sale), ("follower", notify_follower), ("releases", notify_release), ("issues", notify_issues), ("status", notify_status), ("login", login_notification_enabled), ("invite", notify_invite), ] { crate::db::lists::sync_notification_subscription(pool, id, kind, enabled).await?; } Ok(()) } /// Update tip settings. /// /// `tips_enabled` is a capability (whether the creator accepts tips at all) and /// stays a column. `notify_tip` is a notification preference and now lives in /// subscriptions with the other six, so the two are written to different /// places despite arriving from the same form. #[tracing::instrument(skip_all)] pub async fn update_tip_preferences( pool: &PgPool, id: UserId, tips_enabled: bool, notify_tip: bool, ) -> Result<()> { sqlx::query("UPDATE users SET tips_enabled = $2, updated_at = NOW() WHERE id = $1") .bind(id) .bind(tips_enabled) .execute(pool) .await?; crate::db::lists::sync_notification_subscription(pool, id, "tip", notify_tip).await?; Ok(()) } /// Turn one notification off, by the name the unsubscribe link carries. /// /// For the seven original preferences that name is the old `users.notify_*` /// column, because those names are baked into signed URLs already sitting in /// inboxes; they map back to list kinds here rather than being renamed, which /// would invalidate every link ever sent. /// /// A kind with no legacy column (`invite`, migration 195) carries its kind name /// instead. Nothing older is in an inbox to be broken, so there is no column /// name to preserve and inventing one would be cargo cult. #[tracing::instrument(skip_all)] pub async fn disable_notification( pool: &PgPool, user_id: UserId, preference: &str, ) -> Result { let legacy: Option<&str> = crate::db::lists::NOTIFICATION_LISTS .iter() .find(|(_, legacy)| *legacy == preference) .map(|(kind, _)| *kind); let Some(kind) = legacy.or_else(|| { preference .parse::() .ok() .map(|_| preference) }) else { return Ok(false); }; crate::db::lists::sync_notification_subscription(pool, user_id, kind, false).await?; Ok(true) } /// A user who opted into platform status notifications. #[derive(sqlx::FromRow)] pub struct StatusAlertSubscriber { pub id: UserId, pub email: Email, pub display_name: Option, } /// Get all users who opted into platform status notifications. /// /// Hard cap at 10k rows so the monitor's status-change fan-out can't unbox /// an unbounded query into RAM. The 100ms pacing in `monitor.rs` already /// limits fan-out throughput to ~600/minute, anything past 10k would /// chew through Postmark rate limits anyway. If we ever hit the cap a /// WARN fires so we know to switch to a paged dispatch model. #[tracing::instrument(skip_all)] pub async fn get_status_alert_subscribers(pool: &PgPool) -> Result> { const STATUS_SUBSCRIBER_CAP: i64 = 10_000; let rows = sqlx::query_as::<_, StatusAlertSubscriber>( "SELECT u.id, u.email, u.display_name FROM users u \ JOIN list_subscriptions ls ON ls.user_id = u.id \ JOIN lists l ON l.id = ls.list_id AND l.scope = 'platform' AND l.kind = 'status' \ WHERE ls.state IN ('confirmed', 'imported') AND u.deactivated_at IS NULL \ ORDER BY u.id LIMIT $1", ) .bind(STATUS_SUBSCRIBER_CAP) .fetch_all(pool) .await?; if rows.len() as i64 == STATUS_SUBSCRIBER_CAP { tracing::warn!( cap = STATUS_SUBSCRIBER_CAP, "get_status_alert_subscribers hit hard cap; promote to paged dispatch" ); } Ok(rows) } /// Atomically check-and-set broadcast timestamp. Returns false if already sent within 24 hours. #[tracing::instrument(skip_all)] pub async fn try_set_broadcast_at(pool: &PgPool, user_id: UserId) -> Result { let result = sqlx::query( r" UPDATE users SET last_broadcast_at = NOW() WHERE id = $1 AND (last_broadcast_at IS NULL OR last_broadcast_at < NOW() - INTERVAL '24 hours') ", ) .bind(user_id) .execute(pool) .await?; Ok(result.rows_affected() > 0) } /// Release the 24h broadcast slot. Used when a broadcast is refused after the /// slot has already been claimed (e.g. recipient cap exceeded) so the creator /// can retry without waiting a day. #[tracing::instrument(skip_all)] pub async fn clear_broadcast_at(pool: &PgPool, user_id: UserId) -> Result<()> { sqlx::query("UPDATE users SET last_broadcast_at = NULL WHERE id = $1") .bind(user_id) .execute(pool) .await?; Ok(()) }