Skip to main content

turnframe_test/workflows/traveler/
definition.rs

1//! The traveler [`WorkflowDefinition`], its pure transition function and its
2//! receipts.
3
4use 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
34/// Stable rejection codes of the traveler sample.
35pub mod rejection {
36    /// The case does not exist yet.
37    pub const NOT_FOUND: &str = "traveler.not_found";
38    /// The case already exists.
39    pub const ALREADY_EXISTS: &str = "traveler.already_exists";
40    /// The traveler can no longer be edited.
41    pub const LOCKED: &str = "traveler.locked";
42    /// The name is blank.
43    pub const NAME_EMPTY: &str = "traveler.name_empty";
44    /// The name is longer than the field allows.
45    pub const NAME_TOO_LONG: &str = "traveler.name_too_long";
46    /// The name holds a digit or an `@`: it is another field's value.
47    pub const NOT_A_NAME: &str = "traveler.not_a_name";
48    /// The address is not an address.
49    pub const INVALID_EMAIL: &str = "traveler.invalid_email";
50    /// The loyalty number is malformed.
51    pub const INVALID_LOYALTY_NUMBER: &str = "traveler.invalid_loyalty_number";
52    /// The loyalty number is already on the record, so declining it
53    /// would delete a value the user gave.
54    pub const LOYALTY_NUMBER_ALREADY_ANSWERED: &str = "traveler.loyalty_number_already_answered";
55    /// Fields are still missing.
56    pub const INCOMPLETE: &str = "traveler.incomplete";
57    /// The traveler is not a draft.
58    pub const NOT_A_DRAFT: &str = "traveler.not_a_draft";
59    /// Only an active traveler can be archived.
60    pub const NOT_ACTIVE: &str = "traveler.not_active";
61    /// The traveler can no longer be deleted.
62    pub const DELETE_NOT_ALLOWED: &str = "traveler.delete_not_allowed";
63    /// The workflow does not compile this kind of act.
64    pub const UNSUPPORTED_ACT: &str = "traveler.unsupported_act";
65    /// The operation is not in the catalog.
66    pub const UNKNOWN_OPERATION: &str = "traveler.unknown_operation";
67    /// The arguments do not match the operation's schema.
68    pub const INVALID_ARGUMENTS: &str = "traveler.invalid_arguments";
69}
70
71/// Longest accepted name, in characters.
72pub const MAX_NAME_CHARS: usize = 120;
73
74/// Fewest digits a loyalty number has after its two letters.
75pub const LOYALTY_NUMBER_MIN_DIGITS: usize = 6;
76
77/// Most digits a loyalty number has after its two letters.
78pub const LOYALTY_NUMBER_MAX_DIGITS: usize = 10;
79
80/// Returns `true` when `value` is shaped like a loyalty number: two letters, then 6 to
81/// 10 digits, such as `AZ1234567`.
82#[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
91/// Key of the blocking activation requirement.
92pub const ACTIVATION_KEY: &str = "traveler.activation_confirmation";
93
94/// Option that activates the traveler.
95pub const ACTIVATE_OPTION: &str = "activate";
96
97/// Option that keeps the traveler a draft.
98pub 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/// Returns `true` when `value` looks like an address: no space, one `@` with something
110/// on each side and a dot in the domain.
111#[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
123/// A name the domain accepts: not blank, no digit or `@`, not too long. `argument` is
124/// where the act carried it, so a refused name can be asked for again.
125fn 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
169/// Checks a command against the current state without changing anything.
170pub 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            // Declining is how an unanswered question is closed, not how an
230            // answer is erased: a value already on the record stays there until
231            // it is replaced by another value.
232            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
272/// Applies a command, producing the next state and the events to commit.
273pub 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/// The traveler workflow: a passenger profile.
339#[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    /// Builds the workflow: a traveler can be registered at any time, its fields given in
348    /// text, and a card confirms its activation once every field is settled.
349    #[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    /// The workflow with a start precondition on another case: a traveler can only be
358    /// registered while a trip is being filled in. Artificial as a rule, exact as a
359    /// shape, for exercising [`StartPrecondition`].
360    #[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    /// The same workflow with more cards: a review card for an address change and a click
369    /// for deletion or archiving. For exercising the card machinery.
370    #[must_use]
371    pub const fn with_cards(mut self) -> Self {
372        self.with_cards = true;
373        self
374    }
375
376    /// The phase a state projects to.
377    #[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            // The reserved code, not this workflow's own: it says which of the
420            // two mistakes this is, so the state explorer can report a
421            // catalogue that offers what the compiler does not know.
422            _ => 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
435/// An operation writing one text field, labelled in English and Italian.
436fn 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
444/// The blocking activation card of the `AwaitingActivation` phase.
445fn 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
481/// The notice that keeps a decline visible in the view.
482///
483/// The obligation is gone — the assistant must stop asking — but the fact that
484/// the user was asked and said no is not. Encoding the reason in the notice
485/// *code* rather than only in the prose is what lets a later projection, a
486/// prompt or a report branch on it without parsing a sentence.
487fn 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        // A settled-by-decline field has no obligation, so the reason is the
546        // only thing left that distinguishes it from an answered one.
547        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    /// A card that redirects the traveler's notifications names the address.
564    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    /// With [`Self::only_while_a_trip_is_open`], a start that depends on another case: the
583    /// runtime declines to offer it rather than let it be proposed and refused.
584    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    /// A question one act answers is awaited as that act. The loyalty number is answered by
609    /// giving it or by declining it, so its answer is routed.
610    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    /// Each obligation as the question that closes it.
627    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            // No command: the start act makes the workflow the subject of the
655            // turn, and the record is opened by the operation carrying the
656            // first datum. The trip workflow opens a case here instead, so the kit
657            // covers both shapes a workflow may choose.
658            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    /// Fields are said in text; activation is clicked. [`Self::with_cards`] adds the rest.
667    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    /// The operations a view offers, with their summaries in English.
741    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
838/// What each operation does, in Italian.
839const 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
866/// Renders an event whose payload was erased.
867///
868/// This is the domain most likely to need it: a traveler record is names, loyalty
869/// numbers and addresses, and every one of those values is in an event
870/// payload because the receipt that announced the change quoted it. After an
871/// erasure the change is still on record and the copy says so, without
872/// pretending to know what the value was.
873fn 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
893/// Server-authored copy, in English with an Italian translation.
894fn 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}