max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
21 files changed,
+976 insertions,
-11 deletions
| @@ -111,6 +111,17 @@ | |||
| 111 | 111 | pub const SYNCKIT_WARNINGS_PER_TICK: i64 = 200; | |
| 112 | 112 | pub const SYNCKIT_ROTATION_BATCH_MAX: usize = 500; | |
| 113 | 113 | ||
| 114 | + | /// How long an unacknowledged alert waits before it is sent again. | |
| 115 | + | pub const ACKNOWLEDGEMENT_REPEAT_DAYS: i64 = 7; | |
| 116 | + | /// Unanswered sends before the alert is handed to a person and sending stops. | |
| 117 | + | /// Four weekly messages is a month of being ignored, which is long enough to | |
| 118 | + | /// conclude that more of the same will not work. | |
| 119 | + | pub const ACKNOWLEDGEMENT_ESCALATE_AFTER: i32 = 4; | |
| 120 | + | /// Max due alerts one scheduler tick fetches. The sends fan out on the | |
| 121 | + | /// background pool and stamped rows drop out of the due set, so any overflow | |
| 122 | + | /// drains on the following ticks. | |
| 123 | + | pub const ACKNOWLEDGEMENTS_PER_TICK: i64 = 100; | |
| 124 | + | ||
| 114 | 125 | // Subscriptions | |
| 115 | 126 | pub const MIN_SUBSCRIPTION_PRICE_CENTS: i32 = 100; // $1.00 minimum | |
| 116 | 127 |
| @@ -1257,6 +1257,34 @@ | |||
| 1257 | 1257 | ]; | |
| 1258 | 1258 | } | |
| 1259 | 1259 | ||
| 1260 | + | /// What is waiting to be acknowledged. | |
| 1261 | + | /// | |
| 1262 | + | /// Closed and asserted against the `pending_acknowledgements.kind` CHECK | |
| 1263 | + | /// constraint by a test in `db::acknowledgements`: a variant added here without | |
| 1264 | + | /// a migration compiles, passes every unit test, and then fails at INSERT on a | |
| 1265 | + | /// deployed database. | |
| 1266 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 1267 | + | pub enum AckKind { | |
| 1268 | + | /// The creator's Stripe account changed settlement currency, so every price | |
| 1269 | + | /// they have already set is now a number denominated in different money. | |
| 1270 | + | SettlementCurrencyChanged, | |
| 1271 | + | } | |
| 1272 | + | ||
| 1273 | + | impl_str_enum!(AckKind { | |
| 1274 | + | SettlementCurrencyChanged => "settlement_currency_changed", | |
| 1275 | + | }); | |
| 1276 | + | ||
| 1277 | + | impl AckKind { | |
| 1278 | + | pub const ALL: &'static [AckKind] = &[AckKind::SettlementCurrencyChanged]; | |
| 1279 | + | ||
| 1280 | + | /// Subject line and page heading, one source for both. | |
| 1281 | + | pub fn title(self) -> &'static str { | |
| 1282 | + | match self { | |
| 1283 | + | Self::SettlementCurrencyChanged => "Your prices are now in a different currency", | |
| 1284 | + | } | |
| 1285 | + | } | |
| 1286 | + | } | |
| 1287 | + | ||
| 1260 | 1288 | /// Where a subscription stands. | |
| 1261 | 1289 | /// | |
| 1262 | 1290 | /// `Imported` is its own state on purpose. Everything the step-2 backfill |
| @@ -192,6 +192,7 @@ | |||
| 192 | 192 | RevenueSplitId, | |
| 193 | 193 | ModerationActionId, | |
| 194 | 194 | MtThreadId, | |
| 195 | + | PendingAcknowledgementId, | |
| 195 | 196 | ClaimToken, | |
| 196 | 197 | DownloadToken, | |
| 197 | 198 | ); |
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | //! Types (id_types, validated_types, enums, models) are re-exported flat. | |
| 5 | 5 | //! Query functions live in their submodules: `db::users::get_user_by_id()`. | |
| 6 | 6 | ||
| 7 | + | pub mod acknowledgements; // pub so the integration test crate can drive the nag/escalate cycle directly | |
| 7 | 8 | pub mod admin_alerts; | |
| 8 | 9 | pub(crate) mod analytics; | |
| 9 | 10 | pub(crate) mod auth; |
| @@ -541,6 +541,19 @@ | |||
| 541 | 541 | Ok(row.map(|(c,)| c)) | |
| 542 | 542 | } | |
| 543 | 543 | ||
| 544 | + | /// The account behind a Stripe Connect account id. | |
| 545 | + | #[tracing::instrument(skip_all)] | |
| 546 | + | pub async fn get_user_id_by_stripe_account( | |
| 547 | + | pool: &PgPool, | |
| 548 | + | stripe_account_id: &str, | |
| 549 | + | ) -> Result<Option<UserId>> { | |
| 550 | + | let id = sqlx::query_scalar::<_, UserId>("SELECT id FROM users WHERE stripe_account_id = $1") | |
| 551 | + | .bind(stripe_account_id) | |
| 552 | + | .fetch_optional(pool) | |
| 553 | + | .await?; | |
| 554 | + | Ok(id) | |
| 555 | + | } | |
| 556 | + | ||
| 544 | 557 | /// Store a buyer's cross-currency conversion preference. | |
| 545 | 558 | /// | |
| 546 | 559 | /// A preference, not a lock: the checkout form decides each purchase, and this |
| @@ -80,6 +80,7 @@ | |||
| 80 | 80 | ContentExport, | |
| 81 | 81 | CreatorDeparture, | |
| 82 | 82 | UsageLimit, | |
| 83 | + | AcknowledgementRequired, | |
| 83 | 84 | /// Mail to an operator address (support routing, webhook failures, monitor | |
| 84 | 85 | /// alerts). Not addressed to a user account at all, so there is no | |
| 85 | 86 | /// preference to consult; it is named rather than left as an unclassified | |
| @@ -112,6 +113,7 @@ | |||
| 112 | 113 | OperationalKind::ContentExport, | |
| 113 | 114 | OperationalKind::CreatorDeparture, | |
| 114 | 115 | OperationalKind::UsageLimit, | |
| 116 | + | OperationalKind::AcknowledgementRequired, | |
| 115 | 117 | OperationalKind::OperatorAlert, | |
| 116 | 118 | ]; | |
| 117 | 119 | ||
| @@ -137,6 +139,7 @@ | |||
| 137 | 139 | Self::ContentExport => "Content export", | |
| 138 | 140 | Self::CreatorDeparture => "A creator you bought from is leaving", | |
| 139 | 141 | Self::UsageLimit => "Usage limit warning", | |
| 142 | + | Self::AcknowledgementRequired => "Something needs your confirmation", | |
| 140 | 143 | Self::OperatorAlert => "Operator alert", | |
| 141 | 144 | } | |
| 142 | 145 | } | |
| @@ -222,6 +225,10 @@ | |||
| 222 | 225 | "You are approaching or have reached a plan limit. Past it, requests are \ | |
| 223 | 226 | refused, so a silent limit would read as an outage." | |
| 224 | 227 | } | |
| 228 | + | Self::AcknowledgementRequired => { | |
| 229 | + | "Something changed that only you can act on, and it repeats until you \ | |
| 230 | + | confirm you have seen it. Confirming is what stops it." | |
| 231 | + | } | |
| 225 | 232 | Self::OperatorAlert => { | |
| 226 | 233 | "Sent to a Makenotwork operations address, not to a user account." | |
| 227 | 234 | } | |
| @@ -302,6 +309,7 @@ | |||
| 302 | 309 | "Content export", | |
| 303 | 310 | "A creator you bought from is leaving", | |
| 304 | 311 | "Usage limit warning", | |
| 312 | + | "Something needs your confirmation", | |
| 305 | 313 | "Operator alert", | |
| 306 | 314 | ]; | |
| 307 | 315 | assert_eq!( | |
| @@ -341,10 +349,11 @@ | |||
| 341 | 349 | | OperationalKind::ContentExport | |
| 342 | 350 | | OperationalKind::CreatorDeparture | |
| 343 | 351 | | OperationalKind::UsageLimit | |
| 352 | + | | OperationalKind::AcknowledgementRequired | |
| 344 | 353 | | OperationalKind::OperatorAlert => k.justification(), | |
| 345 | 354 | }; | |
| 346 | 355 | } | |
| 347 | - | assert_eq!(OperationalKind::ALL.len(), 20); | |
| 356 | + | assert_eq!(OperationalKind::ALL.len(), 21); | |
| 348 | 357 | } | |
| 349 | 358 | ||
| 350 | 359 | /// The published guide page matches the enum. |