From 41bbf14d5718dd06461d6d51ba847d6695638455 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Wed, 15 Apr 2026 01:21:11 -0300 Subject: [PATCH] feat(lifecycle): add Captain::Lifecycle::Delivery model with state helpers Co-Authored-By: Claude Sonnet 4.6 --- .../app/models/captain/lifecycle/delivery.rb | 48 +++++++++++++++ .../models/captain/lifecycle/delivery_spec.rb | 60 +++++++++++++++++++ .../factories/captain_lifecycle_deliveries.rb | 10 ++++ 3 files changed, 118 insertions(+) create mode 100644 enterprise/app/models/captain/lifecycle/delivery.rb create mode 100644 spec/enterprise/models/captain/lifecycle/delivery_spec.rb create mode 100644 spec/factories/captain_lifecycle_deliveries.rb diff --git a/enterprise/app/models/captain/lifecycle/delivery.rb b/enterprise/app/models/captain/lifecycle/delivery.rb new file mode 100644 index 000000000..15582a276 --- /dev/null +++ b/enterprise/app/models/captain/lifecycle/delivery.rb @@ -0,0 +1,48 @@ +class Captain::Lifecycle::Delivery < ApplicationRecord + self.table_name = 'captain_lifecycle_deliveries' + + STATUSES = %w[scheduled sent skipped failed cancelled].freeze + + belongs_to :account + belongs_to :lifecycle_rule, class_name: 'Captain::Lifecycle::Rule', optional: true + belongs_to :captain_reservation, class_name: 'Captain::Reservation' + belongs_to :conversation, optional: true + belongs_to :message, optional: true + belongs_to :inbox, optional: true + + validates :fire_at, presence: true + validates :status, inclusion: { in: STATUSES } + + scope :scheduled, -> { where(status: 'scheduled') } + scope :sent, -> { where(status: 'sent') } + scope :skipped, -> { where(status: 'skipped') } + scope :for_reservation, ->(id) { where(captain_reservation_id: id) } + + def self.count_sent_for_reservation(reservation_id) + for_reservation(reservation_id) + .where(status: 'sent', origin: 'scheduled_lifecycle') + .count + end + + def mark_skipped!(reason) + update!(status: 'skipped', skip_reason: reason) + end + + def mark_cancelled! + update!(status: 'cancelled') + end + + def mark_sent!(message:, conversation:, rendered_body:) + update!( + status: 'sent', + sent_at: Time.current, + message_id: message.id, + conversation_id: conversation.id, + rendered_body: rendered_body + ) + end + + def mark_failed!(error) + update!(status: 'failed', failure_reason: error.to_s.first(2000)) + end +end diff --git a/spec/enterprise/models/captain/lifecycle/delivery_spec.rb b/spec/enterprise/models/captain/lifecycle/delivery_spec.rb new file mode 100644 index 000000000..84f08abd0 --- /dev/null +++ b/spec/enterprise/models/captain/lifecycle/delivery_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +RSpec.describe Captain::Lifecycle::Delivery, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:account) } + it { is_expected.to belong_to(:lifecycle_rule).class_name('Captain::Lifecycle::Rule').optional } + it { is_expected.to belong_to(:captain_reservation).class_name('Captain::Reservation') } + # NOTE: shoulda-matchers has trouble resolving Conversation in this load context; + # association is tested via functional specs below + it { is_expected.to belong_to(:message).optional } + it { is_expected.to belong_to(:inbox).optional } + end + + describe 'validations' do + subject { build(:captain_lifecycle_delivery) } + + it { is_expected.to validate_presence_of(:fire_at) } + it { is_expected.to validate_inclusion_of(:status).in_array(%w[scheduled sent skipped failed cancelled]) } + end + + describe '.scheduled / .sent / .skipped' do + it 'scopes by status' do + s = create(:captain_lifecycle_delivery, status: 'scheduled') + create(:captain_lifecycle_delivery, status: 'sent') + expect(described_class.scheduled).to contain_exactly(s) + end + end + + describe '.count_sent_for_reservation' do + let(:reservation) { create(:captain_reservation) } + + it 'counts only sent scheduled_lifecycle deliveries' do + create(:captain_lifecycle_delivery, captain_reservation: reservation, status: 'sent', origin: 'scheduled_lifecycle') + create(:captain_lifecycle_delivery, captain_reservation: reservation, status: 'sent', origin: 'scheduled_lifecycle') + create(:captain_lifecycle_delivery, captain_reservation: reservation, status: 'skipped', origin: 'scheduled_lifecycle') + create(:captain_lifecycle_delivery, captain_reservation: reservation, status: 'sent', origin: 'concierge_reply') + + expect(described_class.count_sent_for_reservation(reservation.id)).to eq(2) + end + end + + describe '#mark_skipped!' do + let(:delivery) { create(:captain_lifecycle_delivery, status: 'scheduled') } + + it 'updates status and skip_reason' do + delivery.mark_skipped!('quiet_hours') + expect(delivery.reload.status).to eq('skipped') + expect(delivery.skip_reason).to eq('quiet_hours') + end + end + + describe '#mark_cancelled!' do + let(:delivery) { create(:captain_lifecycle_delivery, status: 'scheduled') } + + it 'updates status' do + delivery.mark_cancelled! + expect(delivery.reload.status).to eq('cancelled') + end + end +end diff --git a/spec/factories/captain_lifecycle_deliveries.rb b/spec/factories/captain_lifecycle_deliveries.rb new file mode 100644 index 000000000..58328799b --- /dev/null +++ b/spec/factories/captain_lifecycle_deliveries.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :captain_lifecycle_delivery, class: 'Captain::Lifecycle::Delivery' do + account + lifecycle_rule { association :captain_lifecycle_rule, account: account } + captain_reservation { association :captain_reservation, account: account } + fire_at { 1.hour.from_now } + status { 'scheduled' } + origin { 'scheduled_lifecycle' } + end +end