hecks 3.1.0 → 3.1.2
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/exe/hecks +1 -1
- data/lib/hecks/chapters.rb +4 -3
- data/lib/hecks/cli/domain_writer.rb +39 -0
- data/lib/hecks/cli/interview_agent.rb +59 -0
- data/lib/hecks/cli/interview_run.rb +87 -0
- data/lib/hecks/cli/interview_session.rb +299 -0
- data/lib/hecks/corpus.rb +1 -0
- data/lib/hecks/doors/cli_runner.rb +1 -1
- data/lib/hecks/doors/usage_cache.rb +123 -0
- data/lib/hecks/hecks/adapters/finding_github.adapter +7 -0
- data/lib/hecks/hecks/adapters/finding_github.rb +114 -0
- data/lib/hecks/hecks/adapters/terminal.rb +15 -0
- data/lib/hecks/hecks/context_map.hecksagon +1 -1
- data/lib/hecks/hecks/custodian.bluebook +36 -0
- data/lib/hecks/hecks/hecks.bluebook +18 -0
- data/lib/hecks/hecks/hecks.hecksagon +8 -2
- data/lib/hecks/hecks/hecks.world +3 -2
- data/lib/hecks/projections/deploy/box/settings.rb +87 -4
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +2 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +18 -5
- data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +17 -1
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +49 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +91 -0
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +58 -0
- data/lib/hecks/projections/deploy/box.rb +216 -20
- 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 +38 -14
- data/lib/hecks/runtime/loader.rb +31 -8
- data/lib/hecks/tickets/bluebook/tickets.bluebook +331 -0
- data/lib/hecks/tickets/bluebook/tickets.hecksagon +11 -0
- data/lib/hecks/tickets/bluebook/tickets.ports.hecksagon +26 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +1 -0
- data/rust/codegen/src/commands.rs +4 -32
- data/rust/codegen/src/json_codec.rs +3 -0
- data/rust/codegen/src/mutations.rs +74 -5
- data/rust/codegen/src/naming.rs +4 -0
- data/rust/codegen/src/types.rs +45 -0
- 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 +17 -2
|
@@ -392,6 +392,23 @@ pub struct Transition {
|
|
|
392
392
|
pub field: String,
|
|
393
393
|
pub to_state: String,
|
|
394
394
|
pub from_states: Vec<String>,
|
|
395
|
+
/// A row with no `from:` admits every state, so no check is emitted for the command.
|
|
396
|
+
pub unconstrained: bool,
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/// The `Option<TransitionCheck>` expression a dispatch function is generated with.
|
|
400
|
+
///
|
|
401
|
+
/// `None` covers both "no transition" and an unconstrained one; an empty `from_states` slice
|
|
402
|
+
/// would refuse every state instead of admitting every state.
|
|
403
|
+
pub fn transition_check_arg(transition: Option<&Transition>) -> String {
|
|
404
|
+
match transition {
|
|
405
|
+
Some(t) if !t.unconstrained => format!(
|
|
406
|
+
"Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
|
|
407
|
+
naming::ruby_inspect_string(&t.field),
|
|
408
|
+
t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
|
|
409
|
+
),
|
|
410
|
+
_ => "None".to_string(),
|
|
411
|
+
}
|
|
395
412
|
}
|
|
396
413
|
|
|
397
414
|
/// Collapses a command's transition rows into the shape `TransitionCheck` wants.
|
|
@@ -417,19 +434,25 @@ pub fn lifecycle_transition_for(command: &Json, aggregate: &Json) -> Option<Tran
|
|
|
417
434
|
}
|
|
418
435
|
}
|
|
419
436
|
let field = lifecycle.get("field").and_then(Json::as_str).unwrap_or("").to_string();
|
|
420
|
-
return Some(Transition { field, to_state: String::new(), from_states });
|
|
437
|
+
return Some(Transition { field, to_state: String::new(), from_states, unconstrained: false });
|
|
421
438
|
}
|
|
422
439
|
|
|
423
440
|
let field = lifecycle.get("field").and_then(Json::as_str).unwrap_or("").to_string();
|
|
424
441
|
let to_state = rows[0].get("to_state").map(Json::to_s).unwrap_or_default();
|
|
425
442
|
let mut from_states: Vec<String> = Vec::new();
|
|
443
|
+
let mut unconstrained = false;
|
|
426
444
|
for row in &rows {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
445
|
+
match row.get_raw("from_state") {
|
|
446
|
+
None | Some(Json::Null) => unconstrained = true,
|
|
447
|
+
Some(from) => {
|
|
448
|
+
let from = from.to_s();
|
|
449
|
+
if !from_states.contains(&from) {
|
|
450
|
+
from_states.push(from);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
430
453
|
}
|
|
431
454
|
}
|
|
432
|
-
Some(Transition { field, to_state, from_states })
|
|
455
|
+
Some(Transition { field, to_state, from_states, unconstrained })
|
|
433
456
|
}
|
|
434
457
|
|
|
435
458
|
/// Right-hand side for a `set`, coercing the source into the target attribute's declared type.
|
|
@@ -782,3 +805,49 @@ fn emit_mutation_line_body(
|
|
|
782
805
|
other => panic!("unsupported mutation op {other:?} — command_skip_reason should have caught this"),
|
|
783
806
|
}
|
|
784
807
|
}
|
|
808
|
+
|
|
809
|
+
#[cfg(test)]
|
|
810
|
+
mod transition_tests {
|
|
811
|
+
use super::*;
|
|
812
|
+
|
|
813
|
+
fn aggregate(rows: &str) -> Json {
|
|
814
|
+
Json::parse(&format!(
|
|
815
|
+
r#"{{"name":"Account","lifecycle":{{"field":"status","default":"draft","transitions":[{rows}]}}}}"#
|
|
816
|
+
))
|
|
817
|
+
.unwrap()
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
fn command(name: &str) -> Json {
|
|
821
|
+
Json::parse(&format!(r#"{{"name":"{name}"}}"#)).unwrap()
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
#[test]
|
|
825
|
+
fn a_transition_without_from_admits_every_state() {
|
|
826
|
+
let account = aggregate(r#"{"command":"Open","to_state":"open","from_state":null}"#);
|
|
827
|
+
let transition = lifecycle_transition_for(&command("Open"), &account).unwrap();
|
|
828
|
+
assert!(transition.unconstrained);
|
|
829
|
+
assert_eq!(transition.to_state, "open");
|
|
830
|
+
assert_eq!(transition_check_arg(Some(&transition)), "None");
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
#[test]
|
|
834
|
+
fn a_transition_with_from_checks_that_state() {
|
|
835
|
+
let account = aggregate(r#"{"command":"Close","to_state":"closed","from_state":"open"}"#);
|
|
836
|
+
let transition = lifecycle_transition_for(&command("Close"), &account).unwrap();
|
|
837
|
+
assert!(!transition.unconstrained);
|
|
838
|
+
assert_eq!(
|
|
839
|
+
transition_check_arg(Some(&transition)),
|
|
840
|
+
r#"Some(crate::kernel::TransitionCheck { field: "status", from_states: &["open"] })"#
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
#[test]
|
|
845
|
+
fn one_unconstrained_row_among_constrained_ones_admits_every_state() {
|
|
846
|
+
let account = aggregate(
|
|
847
|
+
r#"{"command":"Reopen","to_state":"open","from_state":"closed"},
|
|
848
|
+
{"command":"Reopen","to_state":"open","from_state":null}"#,
|
|
849
|
+
);
|
|
850
|
+
let transition = lifecycle_transition_for(&command("Reopen"), &account).unwrap();
|
|
851
|
+
assert_eq!(transition_check_arg(Some(&transition)), "None");
|
|
852
|
+
}
|
|
853
|
+
}
|
data/rust/codegen/src/naming.rs
CHANGED
|
@@ -7,6 +7,7 @@ pub fn scalar_rust_type(type_name: &str) -> Option<&'static str> {
|
|
|
7
7
|
"String" => Some("String"),
|
|
8
8
|
"Integer" => Some("i64"),
|
|
9
9
|
"Float" => Some("f64"),
|
|
10
|
+
"TrueClass" | "FalseClass" => Some("bool"),
|
|
10
11
|
_ => None,
|
|
11
12
|
}
|
|
12
13
|
}
|
|
@@ -29,6 +30,8 @@ pub fn effective_scalar_type(type_name: &str) -> Option<&'static str> {
|
|
|
29
30
|
"String" => Some("String"),
|
|
30
31
|
"Integer" => Some("Integer"),
|
|
31
32
|
"Float" => Some("Float"),
|
|
33
|
+
"TrueClass" => Some("TrueClass"),
|
|
34
|
+
"FalseClass" => Some("FalseClass"),
|
|
32
35
|
_ => None,
|
|
33
36
|
}
|
|
34
37
|
}
|
|
@@ -250,6 +253,7 @@ pub fn scalar_to_value(type_name: &str, rust_expr: &str) -> Option<String> {
|
|
|
250
253
|
"String" => Some(format!("Value::Str({rust_expr}.clone())")),
|
|
251
254
|
"Integer" => Some(format!("Value::Int({rust_expr})")),
|
|
252
255
|
"Float" => Some(format!("Value::Float({rust_expr})")),
|
|
256
|
+
"TrueClass" | "FalseClass" => Some(format!("Value::Bool({rust_expr})")),
|
|
253
257
|
_ => None,
|
|
254
258
|
}
|
|
255
259
|
}
|
data/rust/codegen/src/types.rs
CHANGED
|
@@ -360,3 +360,48 @@ pub fn emit_projected_field_table(aggregate: &Json) -> String {
|
|
|
360
360
|
.collect();
|
|
361
361
|
format!("pub static {name}_PROJECTED_FIELDS: &[crate::kernel::ProjectedFieldSpec] = &[\n{}\n];\n", rows.join("\n"))
|
|
362
362
|
}
|
|
363
|
+
|
|
364
|
+
#[cfg(test)]
|
|
365
|
+
mod tests {
|
|
366
|
+
use super::*;
|
|
367
|
+
|
|
368
|
+
/// A value object with one attribute per Ruby spelling of a boolean.
|
|
369
|
+
fn flag_value_object() -> Json {
|
|
370
|
+
Json::parse(
|
|
371
|
+
r#"{
|
|
372
|
+
"name":"Entry",
|
|
373
|
+
"attributes":[
|
|
374
|
+
{"name":"on","type":"TrueClass","list":false,"optional":false,"default":false},
|
|
375
|
+
{"name":"off","type":"FalseClass","list":false,"optional":false}
|
|
376
|
+
]
|
|
377
|
+
}"#,
|
|
378
|
+
)
|
|
379
|
+
.unwrap()
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
#[test]
|
|
383
|
+
fn boolean_attributes_are_rust_bool_not_a_type_named_for_the_ruby_class() {
|
|
384
|
+
let vo = flag_value_object();
|
|
385
|
+
let generated = emit_value_object(&Exemplar::load(), &vo, &HashMap::new(), &HashMap::new());
|
|
386
|
+
|
|
387
|
+
assert!(generated.contains("pub on: bool,"));
|
|
388
|
+
assert!(generated.contains("pub off: bool,"));
|
|
389
|
+
assert!(generated.contains("Value::Bool(self.on)"));
|
|
390
|
+
assert!(!generated.contains("TrueClass,"));
|
|
391
|
+
assert!(!generated.contains("FalseClass,"));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
#[test]
|
|
395
|
+
fn boolean_attributes_round_trip_through_json_as_bool() {
|
|
396
|
+
let vo = flag_value_object();
|
|
397
|
+
let attributes = vo.get("attributes").map(Json::each).unwrap_or(&[]);
|
|
398
|
+
let by_name = HashMap::new();
|
|
399
|
+
let exemplar = Exemplar::load();
|
|
400
|
+
let from_json = crate::json_codec::emit_from_json_flat(&exemplar, "Entry", attributes, &by_name, None, None, false, false, None);
|
|
401
|
+
let to_json = crate::json_codec::emit_to_json_flat(&exemplar, "Entry", attributes, &by_name, false, &[], None);
|
|
402
|
+
|
|
403
|
+
assert!(from_json.contains("x.as_bool()"));
|
|
404
|
+
assert!(!from_json.contains("TrueClass::from_json"));
|
|
405
|
+
assert!(to_json.contains("crate::kernel::Json::Bool(self.on)"));
|
|
406
|
+
}
|
|
407
|
+
}
|
data/rust/host/HECKS_RELEASE
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.1.
|
|
1
|
+
3.1.2
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
#[test]
|
|
4
|
+
fn cents_render_as_dollars_with_two_places() {
|
|
5
|
+
assert_eq!(dollars(10800), "$108.00");
|
|
6
|
+
assert_eq!(dollars(14805), "$148.05");
|
|
7
|
+
assert_eq!(dollars(5), "$0.05");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
#[test]
|
|
11
|
+
fn the_built_in_receipt_names_the_event_the_first_name_and_the_amount() {
|
|
12
|
+
let body = built_in_body(Some("Ada"), "Yogadelics", Some(10800));
|
|
13
|
+
assert!(body.starts_with("Hi Ada,"));
|
|
14
|
+
assert!(body.contains("You're registered for Yogadelics."));
|
|
15
|
+
assert!(body.contains("$108.00"));
|
|
16
|
+
assert_eq!(receipt_subject("Yogadelics"), "You're registered for Yogadelics");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#[test]
|
|
20
|
+
fn a_built_in_receipt_without_a_name_or_amount_still_reads_cleanly() {
|
|
21
|
+
let body = built_in_body(None, "Yogadelics", None);
|
|
22
|
+
assert!(body.starts_with("Hi,"));
|
|
23
|
+
assert!(!body.contains("payment of"));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[test]
|
|
27
|
+
fn the_sites_finished_email_is_read() {
|
|
28
|
+
let answer = serde_json::json!({"subject": "You're registered for Yogadelics", "body": "Hi Ada,\n\nWhere: Somewhere\n"});
|
|
29
|
+
assert_eq!(
|
|
30
|
+
site_email_from_json(&answer),
|
|
31
|
+
Some(SiteEmail { subject: "You're registered for Yogadelics".into(), body: "Hi Ada,\n\nWhere: Somewhere\n".into() })
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#[test]
|
|
36
|
+
fn an_incomplete_answer_from_the_site_falls_back_to_the_built_in_wording() {
|
|
37
|
+
assert_eq!(site_email_from_json(&serde_json::json!({"error": "no such session"})), None);
|
|
38
|
+
assert_eq!(site_email_from_json(&serde_json::json!({"subject": "Hi", "body": " "})), None);
|
|
39
|
+
}
|
|
@@ -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.2
|
|
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
|
|
@@ -216,10 +216,14 @@ files:
|
|
|
216
216
|
- lib/hecks/cli/doc_coverage.rb
|
|
217
217
|
- lib/hecks/cli/document.rb
|
|
218
218
|
- lib/hecks/cli/domain_stub.rb
|
|
219
|
+
- lib/hecks/cli/domain_writer.rb
|
|
219
220
|
- lib/hecks/cli/expression_projection.rb
|
|
220
221
|
- lib/hecks/cli/follow.rb
|
|
221
222
|
- lib/hecks/cli/history.rb
|
|
223
|
+
- lib/hecks/cli/interview_agent.rb
|
|
222
224
|
- lib/hecks/cli/interview_draft.rb
|
|
225
|
+
- lib/hecks/cli/interview_run.rb
|
|
226
|
+
- lib/hecks/cli/interview_session.rb
|
|
223
227
|
- lib/hecks/cli/ir.rb
|
|
224
228
|
- lib/hecks/cli/mcp.rb
|
|
225
229
|
- lib/hecks/cli/model_check.rb
|
|
@@ -264,6 +268,7 @@ files:
|
|
|
264
268
|
- lib/hecks/doors/ruby_door.rb
|
|
265
269
|
- lib/hecks/doors/ruby_door/aggregate_door.rb
|
|
266
270
|
- lib/hecks/doors/ruby_door/chapter.rb
|
|
271
|
+
- lib/hecks/doors/usage_cache.rb
|
|
267
272
|
- lib/hecks/embryonaut_bluebook.rb
|
|
268
273
|
- lib/hecks/embryonaut_bluebook/lock.rb
|
|
269
274
|
- lib/hecks/embryonaut_bluebook/shape.rb
|
|
@@ -369,6 +374,8 @@ files:
|
|
|
369
374
|
- lib/hecks/hecks/adapters/console_capture.rb
|
|
370
375
|
- lib/hecks/hecks/adapters/deploy_toolchain.adapter
|
|
371
376
|
- lib/hecks/hecks/adapters/deploy_toolchain.rb
|
|
377
|
+
- lib/hecks/hecks/adapters/finding_github.adapter
|
|
378
|
+
- lib/hecks/hecks/adapters/finding_github.rb
|
|
372
379
|
- lib/hecks/hecks/adapters/git.adapter
|
|
373
380
|
- lib/hecks/hecks/adapters/git.rb
|
|
374
381
|
- lib/hecks/hecks/adapters/host_http.adapter
|
|
@@ -533,7 +540,10 @@ files:
|
|
|
533
540
|
- lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl
|
|
534
541
|
- lib/hecks/projections/deploy/box/templates/fetch-secrets.sh
|
|
535
542
|
- lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl
|
|
543
|
+
- lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl
|
|
536
544
|
- lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl
|
|
545
|
+
- lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl
|
|
546
|
+
- lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl
|
|
537
547
|
- lib/hecks/projections/deploy/fargate.rb
|
|
538
548
|
- lib/hecks/projections/deploy/fargate/assembly.rb
|
|
539
549
|
- lib/hecks/projections/deploy/fargate/cdn.rb
|
|
@@ -763,6 +773,9 @@ files:
|
|
|
763
773
|
- lib/hecks/tenancy/bluebook/tenancy.hecksagon
|
|
764
774
|
- lib/hecks/three_zero.rb
|
|
765
775
|
- lib/hecks/three_zero/forms.yml
|
|
776
|
+
- lib/hecks/tickets/bluebook/tickets.bluebook
|
|
777
|
+
- lib/hecks/tickets/bluebook/tickets.hecksagon
|
|
778
|
+
- lib/hecks/tickets/bluebook/tickets.ports.hecksagon
|
|
766
779
|
- lib/hecks/tools.rb
|
|
767
780
|
- lib/hecks/tools/argument_gate_matrix.rb
|
|
768
781
|
- lib/hecks/tools/ci_gate_decision.rb
|
|
@@ -897,6 +910,8 @@ files:
|
|
|
897
910
|
- rust/host/src/web/newsletter/template.rs
|
|
898
911
|
- rust/host/src/web/newsletter/tests.rs
|
|
899
912
|
- rust/host/src/web/newsletter_send.rs
|
|
913
|
+
- rust/host/src/web/registration_receipt.rs
|
|
914
|
+
- rust/host/src/web/registration_receipt/tests.rs
|
|
900
915
|
- rust/host/src/web/registrations.rs
|
|
901
916
|
- rust/lsp/.gitignore
|
|
902
917
|
- rust/lsp/Cargo.lock
|