1use chrono::{DateTime, NaiveDate, Utc};
2use domain::{document, entities, pet, policy, source, vaccine, workflow};
3use serde::Deserialize;
4use uuid::Uuid;
5
6use crate::{booking_triage, checkout_completion, daily_update};
7
8#[derive(Debug, thiserror::Error)]
9pub enum Error {
11 #[error("local smoke fixture is not valid JSON: {0}")]
12 InvalidFixture(#[from] serde_json::Error),
14 #[error("local smoke fixture is missing a required semantic value: {0}")]
15 InvalidDomainValue(String),
17 #[error("local smoke daily-update preview failed: {0}")]
18 DailyUpdate(#[from] daily_update::Error),
20}
21
22pub type Result<T> = core::result::Result<T, Error>;
24
25#[derive(Debug, Deserialize)]
26struct InquiryFixture {
27 source_event_key: String,
28 customer: CustomerFixture,
29 pet: PetFixture,
30 requested_service: String,
31 message: String,
32}
33
34#[derive(Debug, Deserialize)]
35struct CustomerFixture {
36 name: String,
37 email: String,
38}
39
40#[derive(Debug, Deserialize)]
41struct PetFixture {
42 name: String,
43 species: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct SourceEventKey(String);
49
50impl SourceEventKey {
51 fn parse(value: impl Into<String>) -> Result<Self> {
52 let value = value.into().trim().to_owned();
53 if value.is_empty() {
54 return Err(Error::InvalidDomainValue(
55 "source_event_key must be present for smoke provenance".to_owned(),
56 ));
57 }
58 Ok(Self(value))
59 }
60}
61
62impl AsRef<str> for SourceEventKey {
63 fn as_ref(&self) -> &str {
64 &self.0
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Stage {
71 Inquiry,
73 Profile,
75 VaccineDocs,
77 BookingTriage,
79 ConfirmationDraft,
81 CheckInTodayView,
83 StaffNoteDailyUpdateDraft,
85 CheckoutCompletion,
87 FollowUpRetention,
89}
90
91impl Stage {
92 const fn name(self) -> &'static str {
93 match self {
94 Self::Inquiry => "inquiry",
95 Self::Profile => "profile",
96 Self::VaccineDocs => "vaccine_docs",
97 Self::BookingTriage => "booking_triage",
98 Self::ConfirmationDraft => "confirmation_draft",
99 Self::CheckInTodayView => "check_in_today_view",
100 Self::StaffNoteDailyUpdateDraft => "staff_note_daily_update_draft",
101 Self::CheckoutCompletion => "checkout_completion",
102 Self::FollowUpRetention => "follow_up_retention",
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct SmokeBoundaries {
110 draft_only_ai: bool,
111 blocks_live_customer_sends: bool,
112 blocks_provider_or_pms_mutations: bool,
113 blocks_payment_refund_or_discount_actions: bool,
114}
115
116impl SmokeBoundaries {
117 const fn local_demo() -> Self {
118 Self {
119 draft_only_ai: true,
120 blocks_live_customer_sends: true,
121 blocks_provider_or_pms_mutations: true,
122 blocks_payment_refund_or_discount_actions: true,
123 }
124 }
125
126 pub const fn draft_only_ai(&self) -> bool {
128 self.draft_only_ai
129 }
130
131 pub const fn blocks_live_customer_sends(&self) -> bool {
133 self.blocks_live_customer_sends
134 }
135
136 pub const fn blocks_provider_or_pms_mutations(&self) -> bool {
138 self.blocks_provider_or_pms_mutations
139 }
140
141 pub const fn blocks_payment_refund_or_discount_actions(&self) -> bool {
143 self.blocks_payment_refund_or_discount_actions
144 }
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct ReviewEvidenceRef(String);
150
151impl ReviewEvidenceRef {
152 fn new(value: impl Into<String>) -> Self {
153 Self(value.into())
154 }
155}
156
157impl AsRef<str> for ReviewEvidenceRef {
158 fn as_ref(&self) -> &str {
159 &self.0
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct SmokeConfirmationDraft {
166 draft: booking_triage::ConfirmationDraft,
167}
168
169impl SmokeConfirmationDraft {
170 pub const fn requires_customer_message_approval(&self) -> bool {
172 matches!(
173 self.draft.approval_gate(),
174 booking_triage::ApprovalGate::CustomerMessageApproval
175 )
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct ReservationLabel(String);
182
183impl AsRef<str> for ReservationLabel {
184 fn as_ref(&self) -> &str {
185 &self.0
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct TodayView {
192 reservation_labels: Vec<ReservationLabel>,
193 status: entities::reservation::Status,
194}
195
196impl TodayView {
197 pub fn reservation_labels(&self) -> &[ReservationLabel] {
199 &self.reservation_labels
200 }
201
202 pub const fn status(&self) -> &entities::reservation::Status {
204 &self.status
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct CheckoutCompletion {
211 packet: checkout_completion::Packet,
212}
213
214impl CheckoutCompletion {
215 pub fn status(&self) -> entities::reservation::Status {
217 self.packet
218 .suggested_reservation_status()
219 .expect("local smoke checkout completion should suggest checked-out status")
220 }
221
222 pub const fn completion_status(&self) -> checkout_completion::CompletionStatus {
224 self.packet.completion_status()
225 }
226
227 pub fn required_review_gates(&self) -> &[policy::ReviewGate] {
229 self.packet.required_review_gates()
230 }
231
232 pub fn blocked_actions(&self) -> &[checkout_completion::BlockedAction] {
234 self.packet.blocked_actions()
235 }
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub enum RetentionNextAction {
241 DraftRebookingReminderForReview,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct RetentionFollowUp {
248 next_action: RetentionNextAction,
249 review_gate: policy::ReviewGate,
250}
251
252impl RetentionFollowUp {
253 pub const fn next_action(&self) -> RetentionNextAction {
255 self.next_action
256 }
257
258 pub fn review_gate(&self) -> policy::ReviewGate {
260 self.review_gate.clone()
261 }
262}
263
264#[derive(Debug, Clone)]
265pub struct FullChainEvidence {
267 source_event_key: SourceEventKey,
268 stages: Vec<Stage>,
269 boundaries: SmokeBoundaries,
270 #[allow(dead_code)]
271 inquiry: InquiryRecord,
272 #[allow(dead_code)]
273 profile: ProfileEvidence,
274 #[allow(dead_code)]
275 vaccine_docs: VaccineDocumentEvidence,
276 booking_packet: booking_triage::StaffEvaluationPacket,
277 confirmation_draft: SmokeConfirmationDraft,
278 today_view: TodayView,
279 daily_update_preview: daily_update::MvpPreview,
280 checkout_completion: CheckoutCompletion,
281 retention_follow_up: RetentionFollowUp,
282 review_gated_evidence_refs: Vec<ReviewEvidenceRef>,
283}
284
285impl FullChainEvidence {
286 pub const fn source_event_key(&self) -> &SourceEventKey {
288 &self.source_event_key
289 }
290
291 pub fn stage_names(&self) -> Vec<&'static str> {
293 self.stages.iter().map(|stage| stage.name()).collect()
294 }
295
296 pub const fn boundaries(&self) -> &SmokeBoundaries {
298 &self.boundaries
299 }
300
301 pub const fn booking_packet(&self) -> &booking_triage::StaffEvaluationPacket {
303 &self.booking_packet
304 }
305
306 pub const fn confirmation_draft(&self) -> &SmokeConfirmationDraft {
308 &self.confirmation_draft
309 }
310
311 pub const fn today_view(&self) -> &TodayView {
313 &self.today_view
314 }
315
316 pub const fn daily_update_preview(&self) -> &daily_update::MvpPreview {
318 &self.daily_update_preview
319 }
320
321 pub const fn checkout_completion(&self) -> &CheckoutCompletion {
323 &self.checkout_completion
324 }
325
326 pub const fn retention_follow_up(&self) -> &RetentionFollowUp {
328 &self.retention_follow_up
329 }
330
331 pub fn review_gated_evidence_refs(&self) -> &[ReviewEvidenceRef] {
333 &self.review_gated_evidence_refs
334 }
335}
336
337#[derive(Debug, Clone, PartialEq, Eq)]
338struct InquiryRecord {
339 source_event_key: SourceEventKey,
340 requested_service: String,
341 message: String,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
345struct ProfileEvidence {
346 customer: entities::Customer,
347 pet: entities::Pet,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq)]
351struct VaccineDocumentEvidence {
352 document: entities::Document,
353 record: entities::VaccineRecord,
354}
355
356pub fn run_fixture(fixture_json: &str) -> Result<FullChainEvidence> {
358 let fixture: InquiryFixture = serde_json::from_str(fixture_json)?;
359 let source_event_key = SourceEventKey::parse(fixture.source_event_key)?;
360 let ids = SmokeIds::new();
361 let reservation_label = ReservationLabel(format!("REQ-{}", source_event_key.as_ref()));
362 let occurred_at = DateTime::<Utc>::UNIX_EPOCH;
363
364 let inquiry = InquiryRecord {
365 source_event_key: source_event_key.clone(),
366 requested_service: fixture.requested_service,
367 message: fixture.message,
368 };
369 let profile = build_profile(&fixture.customer, &fixture.pet, ids.customer_id, ids.pet_id)?;
370 let vaccine_docs = build_vaccine_document(ids, occurred_at)?;
371 let booking_packet = build_booking_packet(&reservation_label);
372 let confirmation_draft = SmokeConfirmationDraft {
373 draft: booking_packet.confirmation_draft().clone(),
374 };
375 let today_view = TodayView {
376 reservation_labels: vec![reservation_label],
377 status: entities::reservation::Status::CheckedIn,
378 };
379 let daily_update_preview = build_daily_update_preview(ids, &profile.pet.name, occurred_at)?;
380 let checkout_completion = build_checkout_completion(ids, occurred_at)?;
381 let retention_follow_up = RetentionFollowUp {
382 next_action: RetentionNextAction::DraftRebookingReminderForReview,
383 review_gate: policy::ReviewGate::CustomerMessageApproval,
384 };
385
386 let review_gated_evidence_refs = vec![
387 ReviewEvidenceRef::new("vaccine_docs:medical_document_review_required"),
388 ReviewEvidenceRef::new("confirmation:customer_message_approval_required"),
389 ReviewEvidenceRef::new("daily_update:send_stub_blocked_until_human_approval"),
390 ReviewEvidenceRef::new("checkout_completion:customer_message_approval_required"),
391 ];
392
393 assert!(vaccine_docs.document.requires_human_review_before_use());
394 assert!(
395 vaccine_docs
396 .record
397 .requires_human_review_before_compliance()
398 );
399
400 Ok(FullChainEvidence {
401 source_event_key,
402 stages: vec![
403 Stage::Inquiry,
404 Stage::Profile,
405 Stage::VaccineDocs,
406 Stage::BookingTriage,
407 Stage::ConfirmationDraft,
408 Stage::CheckInTodayView,
409 Stage::StaffNoteDailyUpdateDraft,
410 Stage::CheckoutCompletion,
411 Stage::FollowUpRetention,
412 ],
413 boundaries: SmokeBoundaries::local_demo(),
414 inquiry,
415 profile,
416 vaccine_docs,
417 booking_packet,
418 confirmation_draft,
419 today_view,
420 daily_update_preview,
421 checkout_completion,
422 retention_follow_up,
423 review_gated_evidence_refs,
424 })
425}
426
427#[derive(Debug, Clone, Copy)]
428struct SmokeIds {
429 location_id: entities::LocationId,
430 customer_id: entities::CustomerId,
431 pet_id: entities::PetId,
432 reservation_id: entities::reservation::Id,
433 document_id: entities::DocumentId,
434 vaccine_record_id: entities::VaccineRecordId,
435}
436
437impl SmokeIds {
438 fn new() -> Self {
439 Self {
440 location_id: entities::LocationId(Uuid::from_u128(
441 0x0051_0CA1_0000_0000_0000_0000_0000_0001,
442 )),
443 customer_id: entities::CustomerId(Uuid::from_u128(
444 0x0051_0CA1_0000_0000_0000_0000_0000_0002,
445 )),
446 pet_id: entities::PetId(Uuid::from_u128(0x0051_0CA1_0000_0000_0000_0000_0000_0003)),
447 reservation_id: entities::reservation::Id(Uuid::from_u128(
448 0x0051_0CA1_0000_0000_0000_0000_0000_0004,
449 )),
450 document_id: entities::DocumentId(Uuid::from_u128(
451 0x0051_0CA1_0000_0000_0000_0000_0000_0005,
452 )),
453 vaccine_record_id: entities::VaccineRecordId(Uuid::from_u128(
454 0x0051_0CA1_0000_0000_0000_0000_0000_0006,
455 )),
456 }
457 }
458}
459
460fn build_profile(
461 customer: &CustomerFixture,
462 pet: &PetFixture,
463 customer_id: entities::CustomerId,
464 pet_id: entities::PetId,
465) -> Result<ProfileEvidence> {
466 let species = match pet.species.trim().to_ascii_lowercase().as_str() {
467 "dog" => entities::Species::Dog,
468 "cat" => entities::Species::Cat,
469 other => entities::Species::Other(other.to_owned()),
470 };
471
472 Ok(ProfileEvidence {
473 customer: entities::Customer {
474 id: customer_id,
475 full_name: domain::customer::Name::try_new(customer.name.clone()).map_err(invalid)?,
476 email: Some(domain::customer::Email::try_new(customer.email.clone()).map_err(invalid)?),
477 mobile_phone: None,
478 preferred_contact: entities::ContactChannel::Email,
479 portal_account: None,
480 },
481 pet: entities::Pet {
482 id: pet_id,
483 customer_id,
484 name: pet::Name::try_new(pet.name.clone()).map_err(invalid)?,
485 species,
486 birth_date: None,
487 sex: None,
488 spay_neuter_status: entities::SpayNeuterStatus::Unknown,
489 temperament: entities::TemperamentProfile::default(),
490 care_profile: entities::CareProfile::default(),
491 },
492 })
493}
494
495fn build_vaccine_document(
496 ids: SmokeIds,
497 occurred_at: DateTime<Utc>,
498) -> Result<VaccineDocumentEvidence> {
499 let uploaded_by_actor = entities::ActorRef::Customer(ids.customer_id);
500 Ok(VaccineDocumentEvidence {
501 document: entities::Document::builder()
502 .id(ids.document_id)
503 .location_id(ids.location_id)
504 .subject(entities::DocumentSubject::Pet(ids.pet_id))
505 .classification(document::Classification::VaccineProof)
506 .source(document::Source::CustomerUpload)
507 .uploaded_by_actor(uploaded_by_actor)
508 .uploaded_at(occurred_at)
509 .original_file(
510 document::OriginalFile::builder()
511 .filename(document::FileName::try_new("miso-rabies.pdf").map_err(invalid)?)
512 .mime_type(document::MimeType::try_new("application/pdf").map_err(invalid)?)
513 .content_length(document::ContentLengthBytes::try_new(42).map_err(invalid)?)
514 .sha256(
515 document::Sha256Digest::try_new(
516 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
517 )
518 .map_err(invalid)?,
519 )
520 .build(),
521 )
522 .storage_ref(
523 document::StorageRef::builder()
524 .bucket(
525 document::StorageBucket::try_new("local-smoke-documents")
526 .map_err(invalid)?,
527 )
528 .key(
529 document::StorageKey::try_new("fixtures/miso-rabies.pdf")
530 .map_err(invalid)?,
531 )
532 .version(document::StorageVersion::try_new("v1").map_err(invalid)?)
533 .build(),
534 )
535 .virus_scan_status(document::VirusScanStatus::Passed)
536 .pii_redaction_status(document::PiiRedactionStatus::NotRequired)
537 .verification_status(document::Status::AwaitingReview)
538 .build(),
539 record: entities::VaccineRecord::builder()
540 .id(ids.vaccine_record_id)
541 .pet_id(ids.pet_id)
542 .vaccine_name(policy::VaccineName::try_new("Rabies").map_err(invalid)?)
543 .source_document_id(ids.document_id)
544 .status(vaccine::Status::PendingReview)
545 .effective_on(NaiveDate::from_ymd_opt(2026, 1, 1).expect("static date is valid"))
546 .expires_on(NaiveDate::from_ymd_opt(2027, 1, 1).expect("static date is valid"))
547 .review_gate(policy::ReviewGate::MedicalDocumentReview)
548 .build(),
549 })
550}
551
552fn build_booking_packet(
553 reservation_label: &ReservationLabel,
554) -> booking_triage::StaffEvaluationPacket {
555 let evaluation = booking_triage::DeterministicResult::evaluate(vec![
556 booking_triage::rule::Evaluation::pass(
557 booking_triage::rule::Id::DateRangeAndServiceSupported,
558 vec![booking_triage::EvidenceRef::try_new("fixture:boarding-requested").unwrap()],
559 ),
560 booking_triage::rule::Evaluation::pass(
561 booking_triage::rule::Id::AccommodationAvailability,
562 vec![booking_triage::EvidenceRef::try_new("local-demo:capacity-open").unwrap()],
563 ),
564 booking_triage::rule::Evaluation::pass(
565 booking_triage::rule::Id::VaccineRequirements,
566 vec![
567 booking_triage::EvidenceRef::try_new("vaccine:rabies-pending-human-reviewed-path")
568 .unwrap(),
569 ],
570 ),
571 booking_triage::rule::Evaluation::pass(
572 booking_triage::rule::Id::DepositAndPricingRequirements,
573 vec![booking_triage::EvidenceRef::try_new("payment:not-required-local-smoke").unwrap()],
574 ),
575 ]);
576
577 booking_triage::StaffEvaluationPacket::new(
578 booking_triage::Reservation::try_new(reservation_label.as_ref()).unwrap(),
579 evaluation,
580 )
581 .with_ai_recommendation(booking_triage::AiRecommendation::recommend_staff_confirmation(
582 booking_triage::RecommendationText::try_new(
583 "Draft-only local smoke: deterministic gates permit staff to review an offer without mutating PMS records.",
584 )
585 .unwrap(),
586 ))
587 .with_confirmation_draft(booking_triage::ConfirmationDraft::new(
588 booking_triage::CustomerMessageDraft::try_new(
589 "Draft only: staff can review this local demo booking confirmation before any customer send.",
590 )
591 .unwrap(),
592 ))
593}
594
595fn build_daily_update_preview(
596 ids: SmokeIds,
597 pet_name: &pet::Name,
598 occurred_at: DateTime<Utc>,
599) -> Result<daily_update::MvpPreview> {
600 let event = workflow::Event {
601 event_id: workflow::EventId(Uuid::from_u128(0x0051_0CA1_0000_0000_0000_0000_0000_0010)),
602 event_type: workflow::EventType::DailyNoteCreated,
603 occurred_at,
604 actor: entities::ActorRef::Staff {
605 staff_id: entities::StaffId::try_new("local-smoke-kennel").map_err(invalid)?,
606 },
607 location_id: ids.location_id,
608 subject: workflow::Subject::Reservation(ids.reservation_id),
609 policy_context: workflow::PolicyContext {
610 allowed_actions: vec![
611 workflow::AllowedAction::SummarizeCareNotes,
612 workflow::AllowedAction::DraftCustomerMessage,
613 ],
614 automation_level: policy::automation::Level::DraftOnly,
615 required_reviews: vec![policy::ReviewGate::CustomerMessageApproval],
616 },
617 };
618 let note = entities::CareNote::builder()
619 .id(entities::care_note::Id(Uuid::from_u128(
620 0x0051_0CA1_0000_0000_0000_0000_0000_0011,
621 )))
622 .subject(entities::care_note::Subject::Reservation(
623 ids.reservation_id,
624 ))
625 .kind(entities::care_note::Kind::General)
626 .visibility(entities::care_note::Visibility::CustomerVisibleAfterReview)
627 .body(
628 entities::care_note::Body::try_new(
629 "settled into suite, ate dinner, and enjoyed supervised play.",
630 )
631 .map_err(invalid)?,
632 )
633 .author(entities::ActorRef::Staff {
634 staff_id: entities::StaffId::try_new("local-smoke-kennel").map_err(invalid)?,
635 })
636 .recorded_at(occurred_at)
637 .build();
638
639 Ok(daily_update::build_mvp_preview(
640 daily_update::MvpPreviewRequest::builder()
641 .event(event)
642 .pet_name(pet_name.clone())
643 .owner_display_name(domain::customer::Name::try_new("Casey Local").map_err(invalid)?)
644 .policy_snapshot_id(
645 policy::Id::try_new("local-smoke-draft-only-policy").map_err(invalid)?,
646 )
647 .notes(vec![note])
648 .build(),
649 )?)
650}
651
652fn build_checkout_completion(
653 ids: SmokeIds,
654 occurred_at: DateTime<Utc>,
655) -> Result<CheckoutCompletion> {
656 let source_provenance = source::Provenance::builder()
657 .system(source::System::Gingr)
658 .endpoint(source::Endpoint::try_new("GET /reservations/{id}").map_err(invalid)?)
659 .record_id(source::record::Id::try_new("local-smoke-reservation-001").map_err(invalid)?)
660 .extraction_batch(
661 source::ExtractionBatchId::try_new("local-smoke-checkout").map_err(invalid)?,
662 )
663 .pulled_at(source::Timestamp::try_new("2026-06-17T00:00:00Z").map_err(invalid)?)
664 .request_scope(
665 source::RequestScope::try_new("local-smoke-readonly-checkout").map_err(invalid)?,
666 )
667 .schema_version(source::SchemaVersion::try_new("gingr-v0-readonly").map_err(invalid)?)
668 .payload_hash(source::PayloadHash::try_new("sha256:local-smoke-checkout").map_err(invalid)?)
669 .raw_payload_ref(
670 source::RawPayloadRef::try_new("fixtures/gingr/reservation-check-out.json")
671 .map_err(invalid)?,
672 )
673 .build();
674 let staff_handoff = checkout_completion::StaffHandoff::builder()
675 .completed_by(entities::ActorRef::Staff {
676 staff_id: entities::StaffId::try_new("local-smoke-front-desk").map_err(invalid)?,
677 })
678 .completed_at(occurred_at)
679 .belongings_status(checkout_completion::BelongingsStatus::ReturnedToCustomer)
680 .care_summary(
681 checkout_completion::CareSummary::try_new(
682 "Local smoke checkout handoff: belongings returned and care summary ready for review-gated follow-up.",
683 )
684 .map_err(invalid)?,
685 )
686 .departure_notes_review(checkout_completion::DepartureNotesReview::StaffReviewed)
687 .build();
688
689 Ok(CheckoutCompletion {
690 packet: checkout_completion::Workflow::evaluate(
691 checkout_completion::Request::builder()
692 .reservation_id(ids.reservation_id)
693 .source_provenance(source_provenance)
694 .observed_source_status(source::reservation::Status::CheckedOut)
695 .staff_handoff(staff_handoff)
696 .build(),
697 ),
698 })
699}
700
701fn invalid(error: impl ToString) -> Error {
702 Error::InvalidDomainValue(error.to_string())
703}