hecks 3.1.1 → 3.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +36 -0
  3. data/examples/pizzas/bluebook/pizzas.behaviors +70 -0
  4. data/examples/pizzas/bluebook/pizzas.bluebook +138 -0
  5. data/examples/pizzas/bluebook/pizzas.hecksagon +21 -0
  6. data/examples/pizzas/bluebook/pizzas.world +3 -0
  7. data/examples/pizzas/bluebook/translations/2-77625c.bluebook +6 -0
  8. data/examples/pizzas/oidc.json +24 -0
  9. data/examples/pizzas/pizzas_behaviors.hecksagon +19 -0
  10. data/lib/hecks/cli/console.rb +5 -2
  11. data/lib/hecks/doors/cli_runner.rb +6 -0
  12. data/lib/hecks/projections/deploy/box/settings.rb +8 -1
  13. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +1 -0
  14. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +15 -0
  15. data/lib/hecks/projections/deploy/box.rb +32 -2
  16. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +20 -4
  17. data/lib/hecks/projections/deploy/scripts/settings.rb +6 -0
  18. data/lib/hecks/projector/cli_projector.rb +51 -22
  19. data/lib/hecks/quality_control/adapters/agent.rb +63 -7
  20. data/lib/hecks/quality_control/adapters/agent_profile.rb +165 -0
  21. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +23 -9
  22. data/lib/hecks/version.rb +1 -1
  23. data/rust/codegen/src/commands.rs +4 -32
  24. data/rust/codegen/src/mutations.rs +74 -5
  25. data/rust/host/HECKS_RELEASE +1 -1
  26. data/rust/host/src/web/registration_receipt/tests.rs +39 -0
  27. data/rust/host/src/web/registration_receipt.rs +122 -0
  28. data/rust/host/src/web/registrations.rs +14 -2
  29. data/rust/host/src/web.rs +1 -0
  30. metadata +13 -2
@@ -0,0 +1,122 @@
1
+ // The email a registrant gets once their payment has gone through: what they
2
+ // booked and what they paid. Best-effort, like every other mail the host
3
+ // sends: the payment stands whatever delivery does.
4
+
5
+ use super::instances_for;
6
+ use crate::dispatch;
7
+ use crate::ir::PaymentsProvider;
8
+ use crate::journal::LineageConfig;
9
+ use crate::resend::{Email, Mailer};
10
+ use serde_json::{json, Value};
11
+ use std::path::Path;
12
+ use tokio::sync::Mutex;
13
+ use tokio_postgres::Client;
14
+
15
+ #[cfg(test)]
16
+ mod tests;
17
+
18
+ fn dollars(cents: i64) -> String {
19
+ format!("${}.{:02}", cents / 100, cents % 100)
20
+ }
21
+
22
+ fn receipt_subject(event_name: &str) -> String {
23
+ format!("You're registered for {event_name}")
24
+ }
25
+
26
+ /// The subject and body the site built from the wording an editor keeps in
27
+ /// the CMS (when, where and how-to-prepare are filled in there: the venue lives
28
+ /// only in the CMS).
29
+ #[derive(Debug, PartialEq)]
30
+ struct SiteEmail {
31
+ subject: String,
32
+ body: String,
33
+ }
34
+
35
+ fn site_email_from_json(body: &Value) -> Option<SiteEmail> {
36
+ let text = |key: &str| body.get(key).and_then(Value::as_str).filter(|s| !s.trim().is_empty()).map(String::from);
37
+ Some(SiteEmail { subject: text("subject")?, body: text("body")? })
38
+ }
39
+
40
+ /// Asks the site for the finished email (signed, short-lived, bound to this
41
+ /// one event). Best effort: any failure means the host's own built-in wording
42
+ /// goes out instead, so a receipt is always sent.
43
+ async fn site_email(event_slug: &str, first_name: Option<&str>, amount_cents: Option<i64>) -> Option<SiteEmail> {
44
+ let secret = std::env::var("SESSION_SECRET").ok().filter(|s| !s.is_empty())?;
45
+ let token = crate::auth::purpose_token(&secret, "confirmation-email", json!({ "event": event_slug }), 300);
46
+ let mut url = reqwest::Url::parse(&format!("{}/api/confirmation-email", super::newsletter_send::site_url())).ok()?;
47
+ {
48
+ let mut query = url.query_pairs_mut();
49
+ query.append_pair("event", event_slug);
50
+ if let Some(name) = first_name {
51
+ query.append_pair("first_name", name);
52
+ }
53
+ if let Some(cents) = amount_cents {
54
+ query.append_pair("amount_cents", &cents.to_string());
55
+ }
56
+ }
57
+ let response = reqwest::Client::new().get(url).header("x-internal-auth", token).timeout(std::time::Duration::from_secs(8)).send().await.ok()?;
58
+ if !response.status().is_success() {
59
+ return None;
60
+ }
61
+ site_email_from_json(&response.json::<Value>().await.ok()?)
62
+ }
63
+
64
+ /// The host's own wording, used when the site cannot be asked.
65
+ fn built_in_body(first_name: Option<&str>, event_name: &str, amount_cents: Option<i64>) -> String {
66
+ let greeting = first_name.map(|name| format!("Hi {name},")).unwrap_or_else(|| "Hi,".to_string());
67
+ let paid = amount_cents.map(|cents| format!(" We received your payment of {}.", dollars(cents))).unwrap_or_default();
68
+ format!("{greeting}\n\nYou're registered for {event_name}.{paid}\n\nYour seat is held. If anything changes, just reply to this email.\n")
69
+ }
70
+
71
+ /// Mails the receipt for `reference` if its Payment has succeeded. A missing
72
+ /// mailer, registrant address or event is skipped quietly.
73
+ pub(super) async fn send_receipt(reference: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, payments: &PaymentsProvider) {
74
+ let mailer = match Mailer::from_env() {
75
+ Ok(Some(mailer)) => mailer,
76
+ Ok(None) => return,
77
+ Err(e) => {
78
+ eprintln!("registration receipt: {e}");
79
+ return;
80
+ }
81
+ };
82
+ let Ok(read) = dispatch::read(client, wasm_path).await else {
83
+ return;
84
+ };
85
+ let binding = crate::ir::registrations_binding(&config.domain);
86
+ let registrations = instances_for(&read, &binding.registration_prefix());
87
+ let Some((_, registration)) = registrations.iter().find(|(id, _)| id == reference) else {
88
+ return;
89
+ };
90
+ let payment_instances = instances_for(&read, &payments.instance_prefix());
91
+ let payment = payment_instances.iter().find(|(id, _)| id == reference).map(|(_, p)| p);
92
+ if payment.and_then(|p| p.get("status")).and_then(|s| s.as_str()) != Some("succeeded") {
93
+ return;
94
+ }
95
+ let attendee = registration.get("attendee").cloned().unwrap_or_else(|| serde_json::json!({}));
96
+ let Some(to) = attendee.get("email").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) else {
97
+ return;
98
+ };
99
+ let events = instances_for(&read, &binding.event_prefix());
100
+ let event_name = registration
101
+ .get("event_slug")
102
+ .and_then(|v| v.as_str())
103
+ .and_then(|slug| events.iter().find(|(id, _)| id == slug))
104
+ .and_then(|(_, e)| e.get("name"))
105
+ .and_then(|n| n.get("value"))
106
+ .and_then(|v| v.as_str())
107
+ .unwrap_or("your event");
108
+ let amount = payment.and_then(|p| p.get("amount")).and_then(|a| a.get("cents")).and_then(Value::as_i64);
109
+ let first_name = attendee.get("first_name").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
110
+ let from_site = match registration.get("event_slug").and_then(|v| v.as_str()) {
111
+ Some(slug) => site_email(slug, first_name, amount).await,
112
+ None => None,
113
+ };
114
+ let (subject, body) = match from_site {
115
+ Some(email) => (email.subject, email.body),
116
+ None => (receipt_subject(event_name), built_in_body(first_name, event_name, amount)),
117
+ };
118
+ let delivery = mailer.deliver(&Email { to, subject: &subject, body: &body, unsubscribe_url: None }).await;
119
+ if !delivery.ok {
120
+ eprintln!("registration receipt: not delivered: {}", delivery.reason.unwrap_or_default());
121
+ }
122
+ }
@@ -367,6 +367,12 @@ pub(crate) async fn registration_complete_route(
367
367
  }
368
368
  }
369
369
 
370
+ // Only a payment that settled just now earns a receipt; a repeat click was
371
+ // refused above as already settled.
372
+ if outcome == "succeeded" && outcome_result.accepted {
373
+ super::registration_receipt::send_receipt(registration_id, client, wasm_path, config, payments).await;
374
+ }
375
+
370
376
  let read = match dispatch::read(client, wasm_path).await {
371
377
  Ok(r) => r,
372
378
  Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
@@ -675,8 +681,14 @@ pub(crate) async fn webhook_route(
675
681
  // A refusal here (e.g. a redelivered webhook — Stripe's delivery
676
682
  // is at-least-once) is a benign no-op: the payment already holds
677
683
  // the right status, and 200 tells Stripe's retry logic to stop.
678
- if let Err(e) = dispatch::handle_routed(client, wasm_path, verb, json!(reference), facts, None, config, invoker).await {
679
- return respond(500, "text/plain", &format!("{e:#}"));
684
+ match dispatch::handle_routed(client, wasm_path, verb, json!(reference), facts, None, config, invoker).await {
685
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
686
+ // Only the delivery that settles the payment sends the receipt;
687
+ // a redelivery is refused and stays quiet.
688
+ Ok(settled) if settled.accepted && event_type == "checkout.session.completed" => {
689
+ super::registration_receipt::send_receipt(&reference, client, wasm_path, config, payments).await;
690
+ }
691
+ Ok(_) => {}
680
692
  }
681
693
  }
682
694
  }
data/rust/host/src/web.rs CHANGED
@@ -18,6 +18,7 @@ use tokio_postgres::Client;
18
18
 
19
19
  mod newsletter;
20
20
  mod newsletter_send;
21
+ mod registration_receipt;
21
22
  mod registrations;
22
23
 
23
24
  use registrations::payments_routes;
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.1
4
+ version: 3.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-04 00:00:00.000000000 Z
11
+ date: 2026-10-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -35,6 +35,14 @@ executables:
35
35
  extensions: []
36
36
  extra_rdoc_files: []
37
37
  files:
38
+ - examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb
39
+ - examples/pizzas/bluebook/pizzas.behaviors
40
+ - examples/pizzas/bluebook/pizzas.bluebook
41
+ - examples/pizzas/bluebook/pizzas.hecksagon
42
+ - examples/pizzas/bluebook/pizzas.world
43
+ - examples/pizzas/bluebook/translations/2-77625c.bluebook
44
+ - examples/pizzas/oidc.json
45
+ - examples/pizzas/pizzas_behaviors.hecksagon
38
46
  - exe/hecks
39
47
  - lib/hecks.rb
40
48
  - lib/hecks/adapters.rb
@@ -618,6 +626,7 @@ files:
618
626
  - lib/hecks/projector/target.rb
619
627
  - lib/hecks/quality_control/adapters/agent.adapter
620
628
  - lib/hecks/quality_control/adapters/agent.rb
629
+ - lib/hecks/quality_control/adapters/agent_profile.rb
621
630
  - lib/hecks/quality_control/adapters/angle_tools.adapter
622
631
  - lib/hecks/quality_control/adapters/angle_tools.rb
623
632
  - lib/hecks/quality_control/adapters/clearance_recorder.rb
@@ -910,6 +919,8 @@ files:
910
919
  - rust/host/src/web/newsletter/template.rs
911
920
  - rust/host/src/web/newsletter/tests.rs
912
921
  - rust/host/src/web/newsletter_send.rs
922
+ - rust/host/src/web/registration_receipt.rs
923
+ - rust/host/src/web/registration_receipt/tests.rs
913
924
  - rust/host/src/web/registrations.rs
914
925
  - rust/lsp/.gitignore
915
926
  - rust/lsp/Cargo.lock