1use serde::de::DeserializeOwned;
5use turnframe_core::case::CaseRef;
6use turnframe_core::command::{
7 AtomicityScope, ClaimMode, CommandPolicy, ConfirmationPolicy, RiskClass,
8};
9use turnframe_core::error::DomainRejection;
10use turnframe_core::event::{OperationalReceipt, ReceiptEvent, ReceiptSeverity, RedactedEvent};
11use turnframe_core::flow::{
12 ConfirmationSubject, InteractionRequirement, PhaseOwnership, StartPrecondition, ViewOf,
13 WorkflowDefinition, WorkflowNotice, WorkflowView,
14};
15use turnframe_core::ids::{OperationKey, ReceiptId, WorkflowKey, WorkflowVersion};
16use turnframe_core::interaction::{
17 InteractionKind, InteractionOption, InteractionPayload, OptionStyle, StoredInteractionAction,
18};
19use turnframe_core::locale::{Locale, LocalizedText};
20use turnframe_core::operation::OperationSpec;
21use turnframe_core::plan::TargetPolicy;
22use turnframe_core::response::NoticeSeverity;
23use turnframe_core::target::{ResolvedAct, ResolvedActKind};
24
25use crate::workflows::traveler::command::{
26 CreateDraftArgs, DeclineArgs, TravelerCommand, TravelerEvent, ValueArgs, operations,
27};
28use crate::workflows::traveler::state::{
29 DeclineReason, FieldState, TravelerObligation, TravelerOutcome, TravelerPhase, TravelerState,
30 TravelerStatus,
31};
32use crate::workflows::{Applied, PureWorkflow};
33
34pub mod rejection {
36 pub const NOT_FOUND: &str = "traveler.not_found";
38 pub const ALREADY_EXISTS: &str = "traveler.already_exists";
40 pub const LOCKED: &str = "traveler.locked";
42 pub const NAME_EMPTY: &str = "traveler.name_empty";
44 pub const NAME_TOO_LONG: &str = "traveler.name_too_long";
46 pub const NOT_A_NAME: &str = "traveler.not_a_name";
48 pub const INVALID_EMAIL: &str = "traveler.invalid_email";
50 pub const INVALID_LOYALTY_NUMBER: &str = "traveler.invalid_loyalty_number";
52 pub const LOYALTY_NUMBER_ALREADY_ANSWERED: &str = "traveler.loyalty_number_already_answered";
55 pub const INCOMPLETE: &str = "traveler.incomplete";
57 pub const NOT_A_DRAFT: &str = "traveler.not_a_draft";
59 pub const NOT_ACTIVE: &str = "traveler.not_active";
61 pub const DELETE_NOT_ALLOWED: &str = "traveler.delete_not_allowed";
63 pub const UNSUPPORTED_ACT: &str = "traveler.unsupported_act";
65 pub const UNKNOWN_OPERATION: &str = "traveler.unknown_operation";
67 pub const INVALID_ARGUMENTS: &str = "traveler.invalid_arguments";
69}
70
71pub const MAX_NAME_CHARS: usize = 120;
73
74pub const LOYALTY_NUMBER_MIN_DIGITS: usize = 6;
76
77pub const LOYALTY_NUMBER_MAX_DIGITS: usize = 10;
79
80#[must_use]
83pub fn looks_like_loyalty_number(value: &str) -> bool {
84 let (letters, digits) = value.split_at(value.len().min(2));
85 letters.len() == 2
86 && letters.bytes().all(|b| b.is_ascii_alphabetic())
87 && (LOYALTY_NUMBER_MIN_DIGITS..=LOYALTY_NUMBER_MAX_DIGITS).contains(&digits.len())
88 && digits.bytes().all(|b| b.is_ascii_digit())
89}
90
91pub const ACTIVATION_KEY: &str = "traveler.activation_confirmation";
93
94pub const ACTIVATE_OPTION: &str = "activate";
96
97pub const KEEP_DRAFT_OPTION: &str = "keep_draft";
99
100fn reject(code: &'static str) -> DomainRejection {
101 let suffix = code.strip_prefix("traveler.").unwrap_or(code);
102 DomainRejection::new(code, format!("traveler.error.{suffix}"))
103}
104
105fn parse<T: DeserializeOwned>(arguments: &serde_json::Value) -> Result<T, DomainRejection> {
106 serde_json::from_value(arguments.clone()).map_err(|_| reject(rejection::INVALID_ARGUMENTS))
107}
108
109#[must_use]
112pub fn looks_like_email(value: &str) -> bool {
113 if value.chars().any(char::is_whitespace) {
114 return false;
115 }
116 let mut parts = value.split('@');
117 let (Some(local), Some(domain), None) = (parts.next(), parts.next(), parts.next()) else {
118 return false;
119 };
120 !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
121}
122
123fn full_name_fits(value: &str, argument: &str) -> Result<(), DomainRejection> {
126 let trimmed = value.trim();
127 if trimmed.is_empty() {
128 return Err(reject(rejection::NAME_EMPTY)
129 .on_argument(argument)
130 .with_explanation(
131 LocalizedText::new("A traveler's name cannot be empty.").with(
132 Locale::from("it-IT"),
133 "Il nome del viaggiatore non può essere vuoto.",
134 ),
135 ));
136 }
137 if trimmed.chars().any(|c| c.is_ascii_digit() || c == '@') {
138 return Err(reject(rejection::NOT_A_NAME)
139 .on_argument(argument)
140 .with_explanation(
141 LocalizedText::new(
142 "A name has no digits and no @: that looks like another of the traveler's details.",
143 )
144 .with(
145 Locale::from("it-IT"),
146 "Un nome non ha cifre né @: sembra un altro dato del viaggiatore.",
147 ),
148 ));
149 }
150 if trimmed.chars().count() > MAX_NAME_CHARS {
151 return Err(reject(rejection::NAME_TOO_LONG)
152 .with_details(serde_json::json!({ "max_chars": MAX_NAME_CHARS }))
153 .on_argument(argument)
154 .with_explanation(
155 LocalizedText::new(format!(
156 "A traveler's name is at most {MAX_NAME_CHARS} characters."
157 ))
158 .with(
159 Locale::from("it-IT"),
160 format!(
161 "Il nome del viaggiatore è lungo al massimo {MAX_NAME_CHARS} caratteri."
162 ),
163 ),
164 ));
165 }
166 Ok(())
167}
168
169pub fn validate(
171 state: Option<&TravelerState>,
172 command: &TravelerCommand,
173) -> Result<(), DomainRejection> {
174 let Some(state) = state else {
175 return match command {
176 TravelerCommand::CreateDraft => Ok(()),
177 TravelerCommand::CreateNamedDraft { full_name } => {
178 full_name_fits(full_name, "/full_name")
179 }
180 _ => Err(reject(rejection::NOT_FOUND)),
181 };
182 };
183 match command {
184 TravelerCommand::CreateDraft | TravelerCommand::CreateNamedDraft { .. } => {
185 Err(reject(rejection::ALREADY_EXISTS))
186 }
187 TravelerCommand::SetName { value } => {
188 editable(state)?;
189 full_name_fits(value, "/value")
190 }
191 TravelerCommand::ChangeEmail { value } => {
192 editable(state)?;
193 if looks_like_email(value.trim()) {
194 Ok(())
195 } else {
196 Err(reject(rejection::INVALID_EMAIL)
197 .on_argument("/value")
198 .with_explanation(
199 LocalizedText::new("That is not an email address.")
200 .with(Locale::from("it-IT"), "Questo non è un indirizzo email."),
201 ))
202 }
203 }
204 TravelerCommand::SetLoyaltyNumber { value } => {
205 editable(state)?;
206 let trimmed = value.trim();
207 if looks_like_loyalty_number(trimmed) {
208 Ok(())
209 } else {
210 Err(reject(rejection::INVALID_LOYALTY_NUMBER)
211 .with_details(serde_json::json!({
212 "min_digits": LOYALTY_NUMBER_MIN_DIGITS,
213 "max_digits": LOYALTY_NUMBER_MAX_DIGITS
214 }))
215 .on_argument("/value")
216 .with_explanation(
217 LocalizedText::new(
218 "A loyalty number is two letters and then 6 to 10 digits.",
219 )
220 .with(
221 Locale::from("it-IT"),
222 "Un numero fedeltà è fatto di due lettere e poi da 6 a 10 cifre.",
223 ),
224 ))
225 }
226 }
227 TravelerCommand::DeclineLoyaltyNumber { .. } => {
228 editable(state)?;
229 if state.loyalty_number.is_answered() {
233 return Err(reject(rejection::LOYALTY_NUMBER_ALREADY_ANSWERED));
234 }
235 Ok(())
236 }
237 TravelerCommand::Activate => {
238 if state.status != TravelerStatus::Draft {
239 return Err(reject(rejection::NOT_A_DRAFT));
240 }
241 if state.is_complete() {
242 Ok(())
243 } else {
244 Err(reject(rejection::INCOMPLETE))
245 }
246 }
247 TravelerCommand::Archive => {
248 if state.status == TravelerStatus::Active {
249 Ok(())
250 } else {
251 Err(reject(rejection::NOT_ACTIVE))
252 }
253 }
254 TravelerCommand::Delete => {
255 if state.status.is_editable() {
256 Ok(())
257 } else {
258 Err(reject(rejection::DELETE_NOT_ALLOWED))
259 }
260 }
261 }
262}
263
264fn editable(state: &TravelerState) -> Result<(), DomainRejection> {
265 if state.status.is_editable() {
266 Ok(())
267 } else {
268 Err(reject(rejection::LOCKED))
269 }
270}
271
272pub fn apply(
274 state: Option<&TravelerState>,
275 command: &TravelerCommand,
276) -> Result<Applied<TravelerState, TravelerEvent>, DomainRejection> {
277 validate(state, command)?;
278 let Some(state) = state else {
279 if let TravelerCommand::CreateNamedDraft { full_name } = command {
280 let value = full_name.trim().to_owned();
281 let named = TravelerState {
282 full_name: Some(value.clone()),
283 ..TravelerState::default()
284 };
285 return Ok(Applied::new(
286 named,
287 vec![
288 TravelerEvent::DraftCreated,
289 TravelerEvent::NameSet { value },
290 ],
291 ));
292 }
293 return Ok(Applied::new(
294 TravelerState::default(),
295 vec![TravelerEvent::DraftCreated],
296 ));
297 };
298 let mut next = state.clone();
299 let event = match command {
300 TravelerCommand::CreateDraft | TravelerCommand::CreateNamedDraft { .. } => {
301 return Err(reject(rejection::ALREADY_EXISTS));
302 }
303 TravelerCommand::SetName { value } => {
304 let value = value.trim().to_owned();
305 next.full_name = Some(value.clone());
306 TravelerEvent::NameSet { value }
307 }
308 TravelerCommand::ChangeEmail { value } => {
309 let value = value.trim().to_owned();
310 let previous = next.email.replace(value.clone());
311 TravelerEvent::EmailChanged { previous, value }
312 }
313 TravelerCommand::SetLoyaltyNumber { value } => {
314 let value = value.trim().to_owned();
315 next.loyalty_number = FieldState::answered(value.clone());
316 TravelerEvent::LoyaltyNumberSet { value }
317 }
318 TravelerCommand::DeclineLoyaltyNumber { reason } => {
319 next.loyalty_number = FieldState::declined(*reason);
320 TravelerEvent::LoyaltyNumberDeclined { reason: *reason }
321 }
322 TravelerCommand::Activate => {
323 next.status = TravelerStatus::Active;
324 TravelerEvent::Activated
325 }
326 TravelerCommand::Archive => {
327 next.status = TravelerStatus::Archived;
328 TravelerEvent::Archived
329 }
330 TravelerCommand::Delete => {
331 next.status = TravelerStatus::Deleted;
332 TravelerEvent::Deleted
333 }
334 };
335 Ok(Applied::new(next, vec![event]))
336}
337
338#[derive(Debug, Clone, Copy, Default)]
340#[non_exhaustive]
341pub struct TravelerWorkflow {
342 only_while_a_trip_is_open: bool,
343 with_cards: bool,
344}
345
346impl TravelerWorkflow {
347 #[must_use]
350 pub const fn new() -> Self {
351 Self {
352 only_while_a_trip_is_open: false,
353 with_cards: false,
354 }
355 }
356
357 #[must_use]
361 pub const fn only_while_a_trip_is_open() -> Self {
362 Self {
363 only_while_a_trip_is_open: true,
364 with_cards: false,
365 }
366 }
367
368 #[must_use]
371 pub const fn with_cards(mut self) -> Self {
372 self.with_cards = true;
373 self
374 }
375
376 #[must_use]
378 pub fn phase_of(state: &TravelerState) -> TravelerPhase {
379 match state.status {
380 TravelerStatus::Draft if state.is_complete() => TravelerPhase::AwaitingActivation,
381 TravelerStatus::Draft => TravelerPhase::Collecting,
382 TravelerStatus::Active => TravelerPhase::Active,
383 TravelerStatus::Archived => TravelerPhase::Archived,
384 TravelerStatus::Deleted => TravelerPhase::Deleted,
385 }
386 }
387
388 fn compile_operation(
389 operation: &OperationKey,
390 arguments: &serde_json::Value,
391 ) -> Result<Vec<TravelerCommand>, DomainRejection> {
392 let command = match operation.as_str() {
393 operations::CREATE_DRAFT => {
394 let args: CreateDraftArgs = if arguments.is_null() {
395 CreateDraftArgs::default()
396 } else {
397 parse(arguments)?
398 };
399 return Ok(vec![match args.full_name {
400 Some(full_name) => TravelerCommand::CreateNamedDraft { full_name },
401 None => TravelerCommand::CreateDraft,
402 }]);
403 }
404 operations::SET_NAME => TravelerCommand::SetName {
405 value: parse::<ValueArgs>(arguments)?.value,
406 },
407 operations::CHANGE_EMAIL => TravelerCommand::ChangeEmail {
408 value: parse::<ValueArgs>(arguments)?.value,
409 },
410 operations::SET_LOYALTY_NUMBER => TravelerCommand::SetLoyaltyNumber {
411 value: parse::<ValueArgs>(arguments)?.value,
412 },
413 operations::DECLINE_LOYALTY_NUMBER => TravelerCommand::DeclineLoyaltyNumber {
414 reason: parse::<DeclineArgs>(arguments)?.reason,
415 },
416 operations::ACTIVATE => TravelerCommand::Activate,
417 operations::ARCHIVE => TravelerCommand::Archive,
418 operations::DELETE => TravelerCommand::Delete,
419 _ => return Err(reject(turnframe_core::error::UNKNOWN_OPERATION)),
423 };
424 Ok(vec![command])
425 }
426}
427
428fn operation(key: &str, summary: &str) -> OperationSpec {
429 OperationSpec::new(key)
430 .summary(summary)
431 .target(TargetPolicy::RequiresExistingCase)
432 .mutating()
433}
434
435fn field(key: &str, summary: &str, label: &str, label_it: &str) -> OperationSpec {
437 operation(key, summary)
438 .arguments::<ValueArgs>()
439 .argument("value", |a| {
440 a.label(label).label_in("it-IT", label_it).required()
441 })
442}
443
444fn activation_requirement(state: &TravelerState) -> InteractionRequirement {
446 let name = state.full_name.as_deref().unwrap_or("-");
447 let payload = InteractionPayload::new(
448 LocalizedText::new("Activate this traveler?").with("it", "Attivo questo viaggiatore?"),
449 )
450 .with_body(
451 LocalizedText::new(format!("{name} has every required field. Activate it?")).with(
452 "it",
453 format!("{name} ha tutti i campi richiesti. Vuoi attivarlo?"),
454 ),
455 )
456 .with_option(
457 InteractionOption::new(
458 ACTIVATE_OPTION,
459 LocalizedText::new("Activate").with("it", "Attiva"),
460 StoredInteractionAction::ApplyOperation {
461 operation: OperationKey::from(operations::ACTIVATE),
462 arguments: serde_json::Value::Null,
463 freeform_argument: None,
464 },
465 )
466 .with_style(OptionStyle::Primary),
467 )
468 .with_option(
469 InteractionOption::new(
470 KEEP_DRAFT_OPTION,
471 LocalizedText::new("Keep it a draft").with("it", "Lascia in bozza"),
472 StoredInteractionAction::DeclineCommands,
473 )
474 .with_style(OptionStyle::Danger),
475 );
476 InteractionRequirement::blocking(ACTIVATION_KEY, InteractionKind::ConfirmCommand)
477 .with_confirms_risk(RiskClass::ReversibleLowRisk)
478 .with_payload(payload)
479}
480
481fn declined_notice(reason: DeclineReason) -> WorkflowNotice {
488 let (english, italian) = match reason {
489 DeclineReason::NotApplicable => (
490 "This traveler has no loyalty number. Nothing further is needed.",
491 "Questo viaggiatore non ha un numero fedeltà. Non serve altro.",
492 ),
493 DeclineReason::Unknown => (
494 "The loyalty number was not known when it was asked for. \
495 It can still be supplied later.",
496 "Il numero fedeltà non era noto al momento della richiesta. \
497 Si può dare in seguito.",
498 ),
499 DeclineReason::Withheld => (
500 "The traveler chose not to give a loyalty number.",
501 "Il viaggiatore ha scelto di non dare il numero fedeltà.",
502 ),
503 };
504 WorkflowNotice {
505 code: format!("traveler.loyalty_number_declined.{}", reason.code()),
506 severity: NoticeSeverity::Info,
507 text: LocalizedText::new(english).with("it", italian),
508 }
509}
510
511impl WorkflowDefinition for TravelerWorkflow {
512 type State = TravelerState;
513 type Phase = TravelerPhase;
514 type Obligation = TravelerObligation;
515 type Command = TravelerCommand;
516 type Event = TravelerEvent;
517 type Outcome = TravelerOutcome;
518
519 fn key(&self) -> WorkflowKey {
520 WorkflowKey::from("traveler")
521 }
522
523 fn version(&self) -> WorkflowVersion {
524 WorkflowVersion::from("1")
525 }
526
527 fn phase_ownership(&self, phase: &TravelerPhase) -> PhaseOwnership {
528 match phase {
529 TravelerPhase::PreDraft | TravelerPhase::Collecting | TravelerPhase::Active => {
530 PhaseOwnership::System
531 }
532 TravelerPhase::AwaitingActivation => PhaseOwnership::User,
533 TravelerPhase::Archived | TravelerPhase::Deleted => PhaseOwnership::Terminal,
534 }
535 }
536
537 fn project(&self, case_ref: CaseRef, state: Option<&TravelerState>) -> ViewOf<Self> {
538 let version = self.version();
539 let Some(state) = state else {
540 return WorkflowView::new(case_ref, version, TravelerPhase::PreDraft);
541 };
542 let phase = Self::phase_of(state);
543 let mut view =
544 WorkflowView::new(case_ref, version, phase).with_obligations(state.open_obligations());
545 if let (Some(reason), true) = (
548 state.loyalty_number.decline_reason(),
549 state.status.is_editable(),
550 ) {
551 view = view.with_notice(declined_notice(reason));
552 }
553 match phase {
554 TravelerPhase::AwaitingActivation => {
555 view.with_blocking_interaction(activation_requirement(state))
556 }
557 TravelerPhase::Archived => view.with_outcome(TravelerOutcome::Archived),
558 TravelerPhase::Deleted => view.with_outcome(TravelerOutcome::Deleted),
559 TravelerPhase::PreDraft | TravelerPhase::Collecting | TravelerPhase::Active => view,
560 }
561 }
562
563 fn confirmation_subject(
565 &self,
566 _state: Option<&TravelerState>,
567 _view: &ViewOf<Self>,
568 act: &ResolvedAct,
569 ) -> Option<ConfirmationSubject> {
570 if act.operation()?.as_str() != operations::CHANGE_EMAIL {
571 return None;
572 }
573 let email = act.arguments.get("value")?.as_str()?;
574 Some(ConfirmationSubject::asking(
575 LocalizedText::new(format!("Send this traveler's notifications to {email}?")).with(
576 "it",
577 format!("Inviare le notifiche del viaggiatore a {email}?"),
578 ),
579 ))
580 }
581
582 fn start_preconditions(&self) -> Vec<StartPrecondition> {
585 if !self.only_while_a_trip_is_open {
586 return Vec::new();
587 }
588 vec![StartPrecondition::new(
589 "trip",
590 [serde_json::json!("collecting")],
591 LocalizedText::new("A traveler can only be added while a trip is open.").with(
592 "it",
593 "Un viaggiatore si aggiunge solo mentre un viaggio è aperto.",
594 ),
595 )]
596 }
597
598 fn summary(&self) -> Option<String> {
599 Some(String::from(
600 "Travelers: register a person and keep their details: full name, email and loyalty number.",
601 ))
602 }
603
604 fn noun(&self) -> Option<LocalizedText> {
605 Some(LocalizedText::new("traveler").with("it", "viaggiatore"))
606 }
607
608 fn obligation_act(
611 &self,
612 _state: Option<&TravelerState>,
613 obligation: &TravelerObligation,
614 ) -> Option<turnframe_core::flow::ObligationAct> {
615 let operation = match obligation {
616 TravelerObligation::SetName => operations::SET_NAME,
617 TravelerObligation::SetEmail => operations::CHANGE_EMAIL,
618 TravelerObligation::SetLoyaltyNumber => return None,
619 };
620 Some(turnframe_core::flow::ObligationAct::new(
621 operation,
622 ["value"],
623 ))
624 }
625
626 fn obligation_sentence(&self, obligation: &TravelerObligation) -> Option<LocalizedText> {
628 let it = Locale::from("it-IT");
629 Some(match obligation {
630 TravelerObligation::SetName => LocalizedText::new("What is the traveler's full name?")
631 .with(it, "Qual è il nome e cognome del viaggiatore?"),
632 TravelerObligation::SetEmail => {
633 LocalizedText::new("Which email address reaches the traveler?")
634 .with(it, "A quale indirizzo email si scrive al viaggiatore?")
635 }
636 TravelerObligation::SetLoyaltyNumber => {
637 LocalizedText::new("What is the traveler's loyalty number, if they have one?")
638 .with(it, "Qual è il numero fedeltà del viaggiatore, se ce l'ha?")
639 }
640 })
641 }
642
643 fn operations(&self, view: &ViewOf<Self>) -> Vec<OperationSpec> {
644 super::super::in_italian(Self::offered(view), ITALIAN)
645 }
646
647 fn compile_act(
648 &self,
649 _state: Option<&TravelerState>,
650 _view: &ViewOf<Self>,
651 act: &ResolvedAct,
652 ) -> Result<Vec<TravelerCommand>, DomainRejection> {
653 match &act.kind {
654 ResolvedActKind::StartWorkflow => Ok(Vec::new()),
659 ResolvedActKind::ApplyOperation { operation } => {
660 Self::compile_operation(operation, &act.arguments)
661 }
662 _ => Err(reject(rejection::UNSUPPORTED_ACT)),
663 }
664 }
665
666 fn command_policy(
668 &self,
669 _state: Option<&TravelerState>,
670 command: &TravelerCommand,
671 ) -> CommandPolicy {
672 if !self.with_cards && !matches!(command, TravelerCommand::Activate) {
673 return CommandPolicy::low_risk();
674 }
675 match command {
676 TravelerCommand::Delete => CommandPolicy {
677 risk: RiskClass::Destructive,
678 confirmation: ConfirmationPolicy::ExplicitClick,
679 atomicity: AtomicityScope::PerCase,
680 claim_mode: ClaimMode::ServerReceiptOnly,
681 },
682 TravelerCommand::ChangeEmail { .. } => CommandPolicy {
683 risk: RiskClass::SensitiveDataChange,
684 confirmation: ConfirmationPolicy::ReviewCard,
685 atomicity: AtomicityScope::PerCase,
686 claim_mode: ClaimMode::EventReferencedParaphrase,
687 },
688 TravelerCommand::Activate | TravelerCommand::Archive => CommandPolicy {
689 risk: RiskClass::ReversibleLowRisk,
690 confirmation: ConfirmationPolicy::ExplicitClick,
691 atomicity: AtomicityScope::PerCase,
692 claim_mode: ClaimMode::EventReferencedParaphrase,
693 },
694 TravelerCommand::CreateDraft
695 | TravelerCommand::CreateNamedDraft { .. }
696 | TravelerCommand::SetName { .. }
697 | TravelerCommand::SetLoyaltyNumber { .. }
698 | TravelerCommand::DeclineLoyaltyNumber { .. } => CommandPolicy::low_risk(),
699 }
700 }
701
702 fn validate_command(
703 &self,
704 state: Option<&TravelerState>,
705 command: &TravelerCommand,
706 ) -> Result<(), DomainRejection> {
707 validate(state, command)
708 }
709
710 fn receipts(
711 &self,
712 events: &[ReceiptEvent<TravelerEvent>],
713 locale: &Locale,
714 ) -> Vec<OperationalReceipt> {
715 let _ = locale;
716 events
717 .iter()
718 .map(|event| match event {
719 ReceiptEvent::Committed(committed) => {
720 let event_ids = vec![committed.event_id];
721 let status_code = committed.payload.event_type().to_owned();
722 let (title, body) = receipt_copy(&committed.payload);
723 OperationalReceipt {
724 receipt_id: ReceiptId::derive(&event_ids, &status_code),
725 event_ids,
726 severity: ReceiptSeverity::Success,
727 title,
728 body,
729 status_code,
730 artifact_refs: Vec::new(),
731 }
732 }
733 ReceiptEvent::Redacted(redacted) => redacted_receipt(redacted),
734 })
735 .collect()
736 }
737}
738
739impl TravelerWorkflow {
740 fn offered(view: &ViewOf<Self>) -> Vec<OperationSpec> {
742 let fields = || {
743 vec![
744 field(
745 operations::SET_NAME,
746 "Set the traveler's full name.",
747 "full name",
748 "nome e cognome",
749 )
750 .example(
751 "her name is Elena Park.",
752 serde_json::json!({ "value": "Elena Park" }),
753 ),
754 field(
755 operations::CHANGE_EMAIL,
756 "Change the contact address.",
757 "email",
758 "email",
759 ),
760 field(
761 operations::SET_LOYALTY_NUMBER,
762 "Set the traveler's loyalty number.",
763 "loyalty number",
764 "numero fedeltà",
765 )
766 .example(
767 "the loyalty number is AZ1234567",
768 serde_json::json!({ "value": "AZ1234567" }),
769 )
770 .example_not_given("I'll send you the loyalty number later", ["value"]),
771 operation(
772 operations::DECLINE_LOYALTY_NUMBER,
773 "Record that the traveler will not give a loyalty number, \
774 and why: it does not exist, the user does not know it, or the user will \
775 not share it.",
776 )
777 .arguments::<DeclineArgs>()
778 .argument("reason", |a| {
779 a.label("reason")
780 .label_in("it-IT", "motivo")
781 .describe(
782 "Why no number is given: not_applicable when the traveler has \
783 none, never having joined; unknown when the user does not know \
784 it; withheld when the user will not share it.",
785 )
786 .required()
787 })
788 .example(
789 "she never joined the programme, she has no loyalty number",
790 serde_json::json!({ "reason": "not_applicable" }),
791 ),
792 ]
793 };
794 let delete = operation(operations::DELETE, "Remove the traveler.");
795 match view.phase {
796 TravelerPhase::PreDraft => vec![
797 operation(
798 operations::CREATE_DRAFT,
799 "Register a new traveler: start the profile, with the full name when given.",
800 )
801 .target(TargetPolicy::AllowsNewCase)
802 .arguments::<CreateDraftArgs>()
803 .argument("full_name", |a| {
804 a.label("full name")
805 .label_in("it-IT", "nome e cognome")
806 .names_the_record()
807 })
808 .example(
809 "register Sofia Conti",
810 serde_json::json!({ "full_name": "Sofia Conti" }),
811 ),
812 ],
813 TravelerPhase::Collecting => {
814 let mut offered = fields();
815 offered.push(delete);
816 offered
817 }
818 TravelerPhase::AwaitingActivation => {
819 let mut offered = fields();
820 offered.push(operation(operations::ACTIVATE, "Make the traveler usable."));
821 offered.push(delete);
822 offered
823 }
824 TravelerPhase::Active => {
825 let mut offered = fields();
826 offered.push(operation(
827 operations::ARCHIVE,
828 "Keep the traveler for the record only.",
829 ));
830 offered.push(delete);
831 offered
832 }
833 TravelerPhase::Archived | TravelerPhase::Deleted => Vec::new(),
834 }
835 }
836}
837
838const ITALIAN: &[(&str, &str)] = &[
840 (
841 operations::CREATE_DRAFT,
842 "Registra un nuovo viaggiatore: ne inizia il profilo, con nome e cognome se dati.",
843 ),
844 (
845 operations::SET_NAME,
846 "Imposta nome e cognome del viaggiatore.",
847 ),
848 (operations::CHANGE_EMAIL, "Cambia l'indirizzo di contatto."),
849 (
850 operations::SET_LOYALTY_NUMBER,
851 "Imposta il numero fedeltà del viaggiatore.",
852 ),
853 (
854 operations::DECLINE_LOYALTY_NUMBER,
855 "Registra che il viaggiatore non darà un numero fedeltà, e perché: non esiste, \
856 l'utente non lo conosce, o non vuole darlo.",
857 ),
858 (operations::ACTIVATE, "Rende il viaggiatore utilizzabile."),
859 (
860 operations::ARCHIVE,
861 "Conserva il viaggiatore solo come archivio.",
862 ),
863 (operations::DELETE, "Elimina il viaggiatore."),
864];
865
866fn redacted_receipt(redacted: &RedactedEvent) -> OperationalReceipt {
874 let event_ids = vec![redacted.event_id];
875 let status_code = "traveler.detail_erased".to_owned();
876 OperationalReceipt {
877 receipt_id: ReceiptId::derive(&event_ids, &status_code),
878 event_ids,
879 severity: ReceiptSeverity::Info,
880 title: LocalizedText::new("Detail erased").with("it", "Dettaglio cancellato"),
881 body: LocalizedText::new(
882 "This change is still on record; the detail of what changed was erased.",
883 )
884 .with(
885 "it",
886 "Questa modifica resta a registro; il dettaglio di cosa e cambiato e stato cancellato.",
887 ),
888 status_code,
889 artifact_refs: Vec::new(),
890 }
891}
892
893fn receipt_copy(event: &TravelerEvent) -> (LocalizedText, LocalizedText) {
895 match event {
896 TravelerEvent::DraftCreated => (
897 LocalizedText::new("Draft created").with("it", "Bozza creata"),
898 LocalizedText::new("A new traveler profile is open.")
899 .with("it", "È aperto un nuovo profilo viaggiatore."),
900 ),
901 TravelerEvent::NameSet { value } => (
902 LocalizedText::new("Name set").with("it", "Nome impostato"),
903 LocalizedText::new(format!("The traveler's name is now \"{value}\"."))
904 .with("it", format!("Il nome del viaggiatore ora è \"{value}\".")),
905 ),
906 TravelerEvent::EmailChanged { value, .. } => (
907 LocalizedText::new("Address changed").with("it", "Indirizzo aggiornato"),
908 LocalizedText::new(format!("Notifications now go to {value}."))
909 .with("it", format!("Le notifiche ora vanno a {value}.")),
910 ),
911 TravelerEvent::LoyaltyNumberSet { value } => (
912 LocalizedText::new("Loyalty number set").with("it", "Numero fedeltà impostato"),
913 LocalizedText::new(format!("The loyalty number is {value}."))
914 .with("it", format!("Il numero fedeltà è {value}.")),
915 ),
916 TravelerEvent::LoyaltyNumberDeclined { reason } => match reason {
917 DeclineReason::NotApplicable => (
918 LocalizedText::new("No loyalty number").with("it", "Nessun numero fedeltà"),
919 LocalizedText::new("This traveler has no loyalty number.")
920 .with("it", "Questo viaggiatore non ha un numero fedeltà."),
921 ),
922 DeclineReason::Unknown => (
923 LocalizedText::new("Loyalty number not known")
924 .with("it", "Numero fedeltà non noto"),
925 LocalizedText::new("The loyalty number can be given later.")
926 .with("it", "Il numero fedeltà si può dare in seguito."),
927 ),
928 DeclineReason::Withheld => (
929 LocalizedText::new("Loyalty number withheld").with("it", "Numero fedeltà non dato"),
930 LocalizedText::new("The traveler chose not to give a loyalty number.").with(
931 "it",
932 "Il viaggiatore ha scelto di non dare il numero fedeltà.",
933 ),
934 ),
935 },
936 TravelerEvent::Activated => (
937 LocalizedText::new("Traveler active").with("it", "Viaggiatore attivo"),
938 LocalizedText::new("The traveler can now be put on trips.")
939 .with("it", "Il viaggiatore ora si può mettere sui viaggi."),
940 ),
941 TravelerEvent::Archived => (
942 LocalizedText::new("Traveler archived").with("it", "Viaggiatore archiviato"),
943 LocalizedText::new("The traveler is kept for the record only.")
944 .with("it", "Il viaggiatore resta solo a storico."),
945 ),
946 TravelerEvent::Deleted => (
947 LocalizedText::new("Traveler deleted").with("it", "Viaggiatore eliminato"),
948 LocalizedText::new("The traveler was removed.")
949 .with("it", "Il viaggiatore è stato eliminato."),
950 ),
951 }
952}
953
954impl PureWorkflow for TravelerWorkflow {
955 fn apply(
956 &self,
957 state: Option<&TravelerState>,
958 command: &TravelerCommand,
959 ) -> Result<Applied<TravelerState, TravelerEvent>, DomainRejection> {
960 apply(state, command)
961 }
962
963 fn event_type(&self, event: &TravelerEvent) -> String {
964 event.event_type().to_owned()
965 }
966}
967
968#[cfg(test)]
969mod tests {
970 use super::*;
971
972 #[test]
973 fn address_shape_is_checked() {
974 assert!(looks_like_email("marta@aurora.example"));
975 assert!(!looks_like_email("not-an-address"));
976 assert!(!looks_like_email("@aurora.example"));
977 assert!(!looks_like_email("marta@aurora"));
978 assert!(!looks_like_email("marta@@aurora.example"));
979 assert!(!looks_like_email("marta@.example"));
980 }
981
982 #[test]
983 fn an_address_holds_no_space() {
984 assert!(!looks_like_email("the email is marta@aurora.example"));
985 }
986
987 #[test]
988 fn a_loyalty_number_is_two_letters_then_digits() {
989 assert!(looks_like_loyalty_number("AZ1234567"));
990 assert!(!looks_like_loyalty_number("1234567"));
991 assert!(!looks_like_loyalty_number("AZ123"));
992 assert!(!looks_like_loyalty_number("AZ12345678901"));
993 }
994
995 #[test]
996 fn activation_needs_every_field() {
997 let draft = TravelerState {
998 full_name: Some("Marta Bianchi".into()),
999 ..TravelerState::default()
1000 };
1001 let rejection = validate(Some(&draft), &TravelerCommand::Activate).unwrap_err();
1002 assert_eq!(rejection.code.as_str(), rejection::INCOMPLETE);
1003
1004 let complete = TravelerState {
1005 email: Some("marta@aurora.example".into()),
1006 loyalty_number: FieldState::answered("AZ1234567"),
1007 ..draft
1008 };
1009 assert!(validate(Some(&complete), &TravelerCommand::Activate).is_ok());
1010 }
1011
1012 #[test]
1013 fn a_deleted_traveler_refuses_everything() {
1014 let deleted = TravelerState {
1015 status: TravelerStatus::Deleted,
1016 ..TravelerState::default()
1017 };
1018 assert_eq!(
1019 validate(Some(&deleted), &TravelerCommand::Delete)
1020 .unwrap_err()
1021 .code
1022 .as_str(),
1023 rejection::DELETE_NOT_ALLOWED
1024 );
1025 assert_eq!(
1026 validate(
1027 Some(&deleted),
1028 &TravelerCommand::SetName { value: "x".into() }
1029 )
1030 .unwrap_err()
1031 .code
1032 .as_str(),
1033 rejection::LOCKED
1034 );
1035 }
1036
1037 #[test]
1038 fn the_phase_follows_completeness_then_status() {
1039 assert_eq!(
1040 TravelerWorkflow::phase_of(&TravelerState::default()),
1041 TravelerPhase::Collecting
1042 );
1043 let complete = TravelerState {
1044 full_name: Some("Marta Bianchi".into()),
1045 email: Some("marta@aurora.example".into()),
1046 loyalty_number: FieldState::answered("AZ1234567"),
1047 status: TravelerStatus::Draft,
1048 };
1049 assert_eq!(
1050 TravelerWorkflow::phase_of(&complete),
1051 TravelerPhase::AwaitingActivation
1052 );
1053 assert!(complete.open_obligations().is_empty());
1054 }
1055}