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.
- checksums.yaml +4 -4
- data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +36 -0
- data/examples/pizzas/bluebook/pizzas.behaviors +70 -0
- data/examples/pizzas/bluebook/pizzas.bluebook +138 -0
- data/examples/pizzas/bluebook/pizzas.hecksagon +21 -0
- data/examples/pizzas/bluebook/pizzas.world +3 -0
- data/examples/pizzas/bluebook/translations/2-77625c.bluebook +6 -0
- data/examples/pizzas/oidc.json +24 -0
- data/examples/pizzas/pizzas_behaviors.hecksagon +19 -0
- data/lib/hecks/cli/console.rb +5 -2
- data/lib/hecks/doors/cli_runner.rb +6 -0
- data/lib/hecks/projections/deploy/box/settings.rb +8 -1
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +1 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +15 -0
- data/lib/hecks/projections/deploy/box.rb +32 -2
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +20 -4
- data/lib/hecks/projections/deploy/scripts/settings.rb +6 -0
- data/lib/hecks/projector/cli_projector.rb +51 -22
- data/lib/hecks/quality_control/adapters/agent.rb +63 -7
- data/lib/hecks/quality_control/adapters/agent_profile.rb +165 -0
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +23 -9
- data/lib/hecks/version.rb +1 -1
- data/rust/codegen/src/commands.rs +4 -32
- data/rust/codegen/src/mutations.rs +74 -5
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/web/registration_receipt/tests.rs +39 -0
- data/rust/host/src/web/registration_receipt.rs +122 -0
- data/rust/host/src/web/registrations.rs +14 -2
- data/rust/host/src/web.rs +1 -0
- 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", ¢s.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
|
-
|
|
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
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.
|
|
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-
|
|
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
|