spree_cm_commissioner 2.10.0 → 2.10.2.pre.pre1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/app/controllers/spree/admin/vendor_authorized_users_controller.rb +15 -0
- data/app/controllers/spree/admin/vendors_controller_decorator.rb +7 -0
- data/app/errors/spree_cm_commissioner/reserved_blocks/locked_by_operator_error.rb +25 -0
- data/app/finders/spree_cm_commissioner/accommodations/find_variant.rb +2 -2
- data/app/jobs/spree_cm_commissioner/async_operation_job.rb +27 -0
- data/app/jobs/spree_cm_commissioner/ticket_transfers/expire_job.rb +29 -0
- data/app/jobs/spree_cm_commissioner/voting_session_auto_close_job.rb +22 -0
- data/app/jobs/spree_cm_commissioner/voting_session_firestore_publish_job.rb +18 -0
- data/app/models/concerns/spree_cm_commissioner/firestore_connection.rb +27 -0
- data/app/models/concerns/spree_cm_commissioner/order_channel_bitwise.rb +70 -0
- data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +21 -6
- data/app/models/concerns/spree_cm_commissioner/ticket_transferable.rb +13 -2
- data/app/models/concerns/spree_cm_commissioner/vendor_preference.rb +19 -0
- data/app/models/concerns/spree_cm_commissioner/voting_session_config_concern.rb +87 -0
- data/app/models/concerns/spree_cm_commissioner/voting_session_timing_concern.rb +137 -0
- data/app/models/spree_cm_commissioner/async_operation.rb +73 -0
- data/app/models/spree_cm_commissioner/async_operations/seat_lock.rb +76 -0
- data/app/models/spree_cm_commissioner/block.rb +32 -2
- data/app/models/spree_cm_commissioner/guest.rb +5 -0
- data/app/models/spree_cm_commissioner/inventory_item.rb +17 -1
- data/app/models/spree_cm_commissioner/maintenance_tasks/voting_session.rb +6 -5
- data/app/models/spree_cm_commissioner/order_decorator.rb +2 -14
- data/app/models/spree_cm_commissioner/payment_decorator.rb +4 -14
- data/app/models/spree_cm_commissioner/promotion_decorator.rb +19 -0
- data/app/models/spree_cm_commissioner/reserved_block.rb +29 -1
- data/app/models/spree_cm_commissioner/show_episode.rb +3 -1
- data/app/models/spree_cm_commissioner/ticket_transfer.rb +5 -1
- data/app/models/spree_cm_commissioner/variant_decorator.rb +7 -0
- data/app/models/spree_cm_commissioner/vendor_decorator.rb +10 -0
- data/app/models/spree_cm_commissioner/voting_contestant.rb +34 -1
- data/app/models/spree_cm_commissioner/voting_credit_transaction.rb +2 -0
- data/app/models/spree_cm_commissioner/voting_session.rb +44 -83
- data/app/overrides/spree/admin/vendors/_form/sellable_product_types.html.erb.deface +9 -0
- data/app/serializers/spree/v2/tenant/show_episode_serializer.rb +4 -0
- data/app/serializers/spree/v2/tenant/voting_contestant_serializer.rb +2 -1
- data/app/serializers/spree/v2/tenant/voting_session_serializer.rb +6 -1
- data/app/serializers/spree_cm_commissioner/v2/storefront/guest_serializer.rb +1 -0
- data/app/services/spree_cm_commissioner/checkout/complete_decorator.rb +16 -5
- data/app/services/spree_cm_commissioner/fraud_check.rb +2 -2
- data/app/services/spree_cm_commissioner/guests/update_seat.rb +15 -0
- data/app/services/spree_cm_commissioner/intercity_taxi_order/create.rb +4 -2
- data/app/services/spree_cm_commissioner/inventory_items/adjust_available_quantity.rb +83 -0
- data/app/services/spree_cm_commissioner/inventory_items/reset.rb +5 -1
- data/app/services/spree_cm_commissioner/load_test/generate_voting_fixture_data.rb +19 -8
- data/app/services/spree_cm_commissioner/reserved_blocks/cancel.rb +5 -1
- data/app/services/spree_cm_commissioner/reserved_blocks/hold.rb +14 -3
- data/app/services/spree_cm_commissioner/reserved_blocks/reserve.rb +4 -0
- data/app/services/spree_cm_commissioner/seats/apply_locks.rb +105 -0
- data/app/services/spree_cm_commissioner/seats/block_date_iteration.rb +73 -0
- data/app/services/spree_cm_commissioner/seats/lock_blocks.rb +136 -0
- data/app/services/spree_cm_commissioner/seats/lock_state.rb +159 -0
- data/app/services/spree_cm_commissioner/seats/unlock_blocks.rb +71 -0
- data/app/services/spree_cm_commissioner/ticket_transfers/accept.rb +6 -5
- data/app/services/spree_cm_commissioner/ticket_transfers/complete.rb +4 -4
- data/app/services/spree_cm_commissioner/ticket_transfers/expire.rb +7 -1
- data/app/services/spree_cm_commissioner/transit_order/create.rb +10 -4
- data/app/services/spree_cm_commissioner/vote_counters/base.rb +3 -1
- data/app/services/spree_cm_commissioner/vote_counters/increment.rb +1 -1
- data/app/services/spree_cm_commissioner/vote_counters/snapshot_to_db.rb +8 -2
- data/app/services/spree_cm_commissioner/voting_sessions/publish_to_firestore.rb +103 -0
- data/app/views/spree/admin/vendor_authorized_users/index.html.erb +15 -0
- data/config/initializers/spree_permitted_attributes.rb +3 -0
- data/config/locales/en.yml +9 -0
- data/config/locales/km.yml +2 -0
- data/db/migrate/20260806000000_add_metadata_to_cm_reserved_blocks.rb +8 -0
- data/db/migrate/20260806000001_add_metadata_to_cm_inventory_items.rb +6 -0
- data/db/migrate/20260811000000_add_vote_window_to_cm_voting_sessions.rb +14 -0
- data/db/migrate/20260811000001_add_visible_to_cm_voting_contestants.rb +9 -0
- data/db/migrate/20260811000002_add_visible_to_cm_voting_sessions.rb +8 -0
- data/db/migrate/20260811000004_reorder_lifecycle_status_on_cm_voting_sessions.rb +40 -0
- data/db/migrate/20260812000000_create_cm_async_operations.rb +23 -0
- data/db/migrate/20260812000000_replace_timing_mode_with_vote_window_on_cm_voting_sessions.rb +21 -0
- data/db/migrate/20260812125617_add_metadata_to_spree_vendors.rb +6 -0
- data/lib/spree_cm_commissioner/test_helper/factories/async_operation_factory.rb +29 -0
- data/lib/spree_cm_commissioner/test_helper/factories/reserved_block_factory.rb +8 -0
- data/lib/spree_cm_commissioner/test_helper/factories/show_factory.rb +14 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +33 -5
- data/app/jobs/spree_cm_commissioner/expire_ticket_transfer_job.rb +0 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e5552124388d0792051115573a9cc5e51044c8edcf7f8e89c6f03b43d2ebd0ab
|
|
4
|
+
data.tar.gz: '0694645f790079a03ebad09626ca155e7495c37ea8b45f83064a3c31b2aa6f75'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 06b79003d8dee84088c17d6cd6f90b7d2b7d5eeca20b4045ca1775b467b3455b66898e72e47d33e9655a6b8b7c1b36dcef355b8b4ff5258daa066d87e8cdfb02
|
|
7
|
+
data.tar.gz: 386411dbfdd18ce5d25ade6d55e045b1bcaea788d3b8cb49437723789ab8e1e5e7e1b77e4cbece9ee962427f8b0c5c603c1cd1cdc091400ca8cf6e860dfd39a4
|
data/Gemfile.lock
CHANGED
|
@@ -14,6 +14,8 @@ module Spree
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def update_telegram_chat
|
|
17
|
+
@vendor.update(preferred_telegram_alert_channels: submitted_alert_channels_mask)
|
|
18
|
+
|
|
17
19
|
context = ::SpreeCmCommissioner::TelegramChatsFinder.call(
|
|
18
20
|
telegram_chat_name: params['vendor']['preferred_telegram_chat_name'],
|
|
19
21
|
telegram_chat_type: params['vendor']['preferred_telegram_chat_type']
|
|
@@ -34,6 +36,19 @@ module Spree
|
|
|
34
36
|
redirect_to collection_url(@vendor)
|
|
35
37
|
end
|
|
36
38
|
|
|
39
|
+
# OR the bits, never sum them. Sum is not idempotent: if the same bit arrives twice
|
|
40
|
+
# (duplicated checkbox, double-submit, hand-crafted post) 1 + 1 = 2, which silently means
|
|
41
|
+
# google_form instead of spree. ["0","1","1","16"].sum => 18 => [google_form, offline_sell];
|
|
42
|
+
# the same input OR'd => 17 => [spree, offline_sell].
|
|
43
|
+
#
|
|
44
|
+
# Masking against the known bits also drops junk from a crafted post (999 => 7).
|
|
45
|
+
def submitted_alert_channels_mask
|
|
46
|
+
known = SpreeCmCommissioner::OrderChannelBitwise::CHANNEL_ALERT_BITS.values.reduce(:|)
|
|
47
|
+
|
|
48
|
+
Array(params.dig(:vendor, :telegram_alert_channels))
|
|
49
|
+
.reduce(0) { |acc, value| acc | value.to_i } & known
|
|
50
|
+
end
|
|
51
|
+
|
|
37
52
|
# @overrided
|
|
38
53
|
def model_class
|
|
39
54
|
Spree::User
|
|
@@ -5,10 +5,17 @@ module Spree
|
|
|
5
5
|
base.before_action :build_logo, only: %i[create update]
|
|
6
6
|
base.before_action :build_payment_qrcode, only: %i[create update]
|
|
7
7
|
base.before_action :contruct_nearby_place, only: %i[create update]
|
|
8
|
+
base.before_action :compact_sellable_product_types, only: %i[create update]
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
private
|
|
11
12
|
|
|
13
|
+
def compact_sellable_product_types
|
|
14
|
+
return unless permitted_resource_params.key?(:sellable_product_types)
|
|
15
|
+
|
|
16
|
+
permitted_resource_params[:sellable_product_types] = permitted_resource_params[:sellable_product_types].compact_blank
|
|
17
|
+
end
|
|
18
|
+
|
|
12
19
|
def contruct_nearby_place
|
|
13
20
|
return unless permitted_resource_params.key?(:nearby_places_attributes)
|
|
14
21
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module ReservedBlocks
|
|
3
|
+
# Raised when a customer tries to hold or reserve a seat an operator has taken off sale.
|
|
4
|
+
#
|
|
5
|
+
# The customer-facing message deliberately says only "not available" — it must not leak the
|
|
6
|
+
# operator's reason (e.g. "held for staff"), which lives in private_metadata.
|
|
7
|
+
class LockedByOperatorError < StandardError
|
|
8
|
+
attr_reader :block_label
|
|
9
|
+
|
|
10
|
+
def initialize(block_label = nil)
|
|
11
|
+
@block_label = block_label
|
|
12
|
+
super()
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# override
|
|
16
|
+
def message
|
|
17
|
+
if block_label.present?
|
|
18
|
+
I18n.t('line_item.validation.blocks_are_locked_by_operator_with_label', label: block_label)
|
|
19
|
+
else
|
|
20
|
+
I18n.t('line_item.validation.blocks_are_locked_by_operator')
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -15,8 +15,8 @@ module SpreeCmCommissioner
|
|
|
15
15
|
.joins(:inventory_items)
|
|
16
16
|
.where(vendor_id: vendor_id)
|
|
17
17
|
.where(inventory_items: { inventory_date: stay_dates })
|
|
18
|
-
.where('CAST(public_metadata->\'cm_options\'->>\'number-of-adults\' AS INTEGER) +
|
|
19
|
-
CAST(public_metadata->\'cm_options\'->>\'number-of-kids\' AS INTEGER) >= ?', number_of_guests
|
|
18
|
+
.where('CAST(spree_variants.public_metadata->\'cm_options\'->>\'number-of-adults\' AS INTEGER) +
|
|
19
|
+
CAST(spree_variants.public_metadata->\'cm_options\'->>\'number-of-kids\' AS INTEGER) >= ?', number_of_guests
|
|
20
20
|
)
|
|
21
21
|
.where('inventory_items.quantity_available > 0')
|
|
22
22
|
.distinct
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# Runs any AsyncOperation subclass. Same shape as ExportJob: load the record, call the one method the
|
|
3
|
+
# subclass implements.
|
|
4
|
+
#
|
|
5
|
+
# ApplicationJob, not ApplicationUniqueJob: `unique :until_executed` keys on the job arguments, and
|
|
6
|
+
# those carry a per-record id, so uniqueness could never collide -- it would buy a Redis lock for
|
|
7
|
+
# nothing. The in_progress? guard below is what actually makes a duplicate enqueue a no-op.
|
|
8
|
+
class AsyncOperationJob < ApplicationJob
|
|
9
|
+
queue_as :default
|
|
10
|
+
|
|
11
|
+
def perform(options = {})
|
|
12
|
+
operation = SpreeCmCommissioner::AsyncOperation.find_by(id: options[:async_operation_id])
|
|
13
|
+
return if operation.blank?
|
|
14
|
+
|
|
15
|
+
# Guard on `done` only, not on "not in progress". An exception below marks the operation failed and
|
|
16
|
+
# re-raises so Sidekiq retries -- guarding on in_progress? would make every one of those retries
|
|
17
|
+
# return here immediately, which is the opposite of retrying. Completed work is the thing that must
|
|
18
|
+
# never run twice; a failed operation is exactly what a retry is for.
|
|
19
|
+
return if operation.done?
|
|
20
|
+
|
|
21
|
+
operation.process!
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
operation&.fail!(e.message)
|
|
24
|
+
raise # ApplicationJob logs it; Sidekiq retries; Sentry captures.
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module TicketTransfers
|
|
3
|
+
class ExpireJob < ApplicationJob
|
|
4
|
+
# Same window as OrderHolds::LockForPayment — gives an in-flight gateway
|
|
5
|
+
# redirect/webhook (e.g. PayWay, ACLEDA) room to resolve before we retry.
|
|
6
|
+
RETRY_DELAY = ENV.fetch('PAYMENT_LOCK_MIN_DURATION_IN_MINUTES', '5').to_i.minutes
|
|
7
|
+
MAX_RETRIES = 1
|
|
8
|
+
UNRESOLVED_PAYMENT_STATES = %w[checkout pending processing].freeze
|
|
9
|
+
|
|
10
|
+
def perform(transfer_id, attempt = 0)
|
|
11
|
+
ticket_transfer = SpreeCmCommissioner::TicketTransfer.find_by(id: transfer_id)
|
|
12
|
+
return unless ticket_transfer
|
|
13
|
+
|
|
14
|
+
if payment_in_progress?(ticket_transfer) && attempt < MAX_RETRIES
|
|
15
|
+
self.class.set(wait: RETRY_DELAY).perform_later(transfer_id, attempt + 1)
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
SpreeCmCommissioner::TicketTransfers::Expire.call(ticket_transfer: ticket_transfer)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def payment_in_progress?(ticket_transfer)
|
|
25
|
+
ticket_transfer.order&.payments&.where(state: UNRESOLVED_PAYMENT_STATES)&.exists? || false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# Flips the Firestore `state` to closed once a manually-started vote window elapses.
|
|
3
|
+
#
|
|
4
|
+
# Enqueued by VotingSessionTimingConcern#start_vote! with wait_until: vote_close_at. Vote
|
|
5
|
+
# rejection does not depend on this job — can_vote? already gates on the persisted
|
|
6
|
+
# vote_close_at — so a missed run costs a stale label in the app, never an accepted late vote.
|
|
7
|
+
class VotingSessionAutoCloseJob < ApplicationJob
|
|
8
|
+
queue_as :default
|
|
9
|
+
|
|
10
|
+
def perform(voting_session_id)
|
|
11
|
+
voting_session = SpreeCmCommissioner::VotingSession.find_by(id: voting_session_id)
|
|
12
|
+
return if voting_session.nil?
|
|
13
|
+
|
|
14
|
+
# vote_close_at may have moved since this was scheduled (a restart, or the organizer
|
|
15
|
+
# pressing Stop Vote and starting a new window). Only publish once the window has genuinely
|
|
16
|
+
# elapsed; otherwise the run that matches the new vote_close_at will handle it.
|
|
17
|
+
return if voting_session.vote_close_at.present? && Time.current < voting_session.vote_close_at
|
|
18
|
+
|
|
19
|
+
SpreeCmCommissioner::VotingSessions::PublishToFirestore.call(voting_session: voting_session)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# Pushes a voting session's live state to Firestore off the request cycle, so an organizer
|
|
3
|
+
# pressing "Start Vote" never waits on a Firestore round-trip.
|
|
4
|
+
#
|
|
5
|
+
# Takes an id rather than the record so a retry publishes the state as it is *now*, not as it
|
|
6
|
+
# was when the job was enqueued — a rapid start/pause/stop sequence must not have a stale
|
|
7
|
+
# retry overwrite the current state.
|
|
8
|
+
class VotingSessionFirestorePublishJob < ApplicationJob
|
|
9
|
+
queue_as :default
|
|
10
|
+
|
|
11
|
+
def perform(voting_session_id)
|
|
12
|
+
voting_session = SpreeCmCommissioner::VotingSession.find_by(id: voting_session_id)
|
|
13
|
+
return if voting_session.nil?
|
|
14
|
+
|
|
15
|
+
SpreeCmCommissioner::VotingSessions::PublishToFirestore.call(voting_session: voting_session)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require 'google/cloud/firestore'
|
|
2
|
+
|
|
3
|
+
module SpreeCmCommissioner
|
|
4
|
+
# Shared Cloud Firestore client construction.
|
|
5
|
+
#
|
|
6
|
+
# The waiting-room services each inline this same pair (see WaitingGuestsCaller#firestore);
|
|
7
|
+
# this concern is the extracted version for new writers, plus a #firestore_available? guard so
|
|
8
|
+
# environments without the credential (dev, test, CI) no-op instead of raising on connect.
|
|
9
|
+
module FirestoreConnection
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
def firestore_available?
|
|
13
|
+
service_account.present?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def firestore
|
|
17
|
+
@firestore ||= Google::Cloud::Firestore.new(
|
|
18
|
+
project_id: service_account[:project_id],
|
|
19
|
+
credentials: service_account
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def service_account
|
|
24
|
+
@service_account ||= Rails.application.credentials.cloud_firestore_service_account
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Owns everything about an order's `channel`: the allowed values, how a stored value
|
|
2
|
+
# resolves back to one of them, and the bitmask vendors use to choose which channels
|
|
3
|
+
# may alert their Telegram group.
|
|
4
|
+
#
|
|
5
|
+
# `channel` is validated by PREFIX, not equality, because the CSV importer stores
|
|
6
|
+
# "google_form-<import_order_id>-<slug>". Always compare via #channel_key, never `==`.
|
|
7
|
+
module SpreeCmCommissioner
|
|
8
|
+
module OrderChannelBitwise
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
SPREE_CHANNEL = 'spree'.freeze
|
|
12
|
+
GOOGLE_FORM_CHANNEL = 'google_form'.freeze
|
|
13
|
+
TELEGRAM_CHANNEL = 'telegram'.freeze
|
|
14
|
+
TICKET_TRANSFER_CHANNEL = 'ticket_transfer'.freeze
|
|
15
|
+
OFFLINE_SELL_CHANNEL = 'offline_sell'.freeze
|
|
16
|
+
|
|
17
|
+
ALLOWED_CHANNEL_PREFIXES = [
|
|
18
|
+
SPREE_CHANNEL, GOOGLE_FORM_CHANNEL, TELEGRAM_CHANNEL, TICKET_TRANSFER_CHANNEL, OFFLINE_SELL_CHANNEL
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# Matched longest-first so a future 'spree_x' can never be swallowed by 'spree'.
|
|
22
|
+
SORTED_CHANNEL_PREFIXES = ALLOWED_CHANNEL_PREFIXES.sort_by { |prefix| -prefix.length }.freeze
|
|
23
|
+
|
|
24
|
+
# Bit values are PERSISTED in vendor preferences and are a permanent contract.
|
|
25
|
+
# Never renumber, reorder, or reuse a bit. To retire a channel, leave its bit
|
|
26
|
+
# burned and take the next free power of two.
|
|
27
|
+
#
|
|
28
|
+
# Deliberately literal, never derived from ALLOWED_CHANNEL_PREFIXES.index —
|
|
29
|
+
# reordering that array would silently remap every vendor's saved setting.
|
|
30
|
+
CHANNEL_ALERT_BITS = {
|
|
31
|
+
SPREE_CHANNEL => 1, # 1 << 0
|
|
32
|
+
GOOGLE_FORM_CHANNEL => 2, # 1 << 1
|
|
33
|
+
TELEGRAM_CHANNEL => 4, # 1 << 2
|
|
34
|
+
TICKET_TRANSFER_CHANNEL => 8, # 1 << 3
|
|
35
|
+
OFFLINE_SELL_CHANNEL => 16 # 1 << 4
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Every bit currently in use, for masking untrusted input down to known channels.
|
|
39
|
+
KNOWN_CHANNEL_ALERT_BITS = CHANNEL_ALERT_BITS.values.reduce(:|).freeze
|
|
40
|
+
|
|
41
|
+
# Only online orders alert by default. google_form & ticket_transfer set the state column
|
|
42
|
+
# directly instead of firing the state machine, so they have never sent an alert; this
|
|
43
|
+
# default therefore encodes today's real behaviour rather than changing it.
|
|
44
|
+
DEFAULT_TELEGRAM_ALERT_CHANNELS = CHANNEL_ALERT_BITS[SPREE_CHANNEL]
|
|
45
|
+
|
|
46
|
+
included do
|
|
47
|
+
validate :validate_channel_prefix, if: :channel_changed?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The stored channel may carry a suffix — the importer writes
|
|
51
|
+
# "google_form-<import_order_id>-<slug>" — so the prefix, not the whole
|
|
52
|
+
# string, is the channel's identity. Returns nil for an unrecognized channel.
|
|
53
|
+
def channel_key
|
|
54
|
+
SORTED_CHANNEL_PREFIXES.find { |prefix| channel&.start_with?(prefix) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Order was created by an operator through the back-office Sell Ticket console.
|
|
58
|
+
def offline_sell?
|
|
59
|
+
channel_key == OFFLINE_SELL_CHANNEL
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def validate_channel_prefix
|
|
65
|
+
return if channel_key.present?
|
|
66
|
+
|
|
67
|
+
errors.add(:channel, "must start with one of the following: #{ALLOWED_CHANNEL_PREFIXES.join(', ')}")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Make sure to put this concern below other concern or methods that generating additional order info like guests, seat number, etc.
|
|
2
2
|
# This will ensure that when each notification is sent with neccessary data.
|
|
3
3
|
module SpreeCmCommissioner
|
|
4
|
-
module OrderStateMachine
|
|
4
|
+
module OrderStateMachine # rubocop:disable Metrics/ModuleLength
|
|
5
5
|
extend ActiveSupport::Concern
|
|
6
6
|
|
|
7
7
|
included do # rubocop:disable Metrics/BlockLength
|
|
@@ -271,15 +271,30 @@ module SpreeCmCommissioner
|
|
|
271
271
|
return unless telegram_alert_enabled?
|
|
272
272
|
|
|
273
273
|
vendor_list.each do |vendor|
|
|
274
|
-
|
|
274
|
+
# `next`, not `return`: the mask is per-vendor, so one opted-out vendor on a
|
|
275
|
+
# multi-vendor order must not suppress the others.
|
|
276
|
+
next unless vendor.alerts_telegram_for_channel?(channel_key)
|
|
277
|
+
|
|
275
278
|
chat_id = vendor.preferred_telegram_chat_id
|
|
276
|
-
if chat_id.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
279
|
+
next if chat_id.blank?
|
|
280
|
+
|
|
281
|
+
factory = OrderTelegramMessageFactory.new(title: vendor_alert_title(vendor), order: self, vendor: vendor)
|
|
282
|
+
TelegramNotificationSenderJob.perform_later(chat_id: chat_id, message: factory.message, parse_mode: factory.parse_mode)
|
|
280
283
|
end
|
|
281
284
|
end
|
|
282
285
|
|
|
286
|
+
# White-label vendors announce their own brand; platform vendors stay BookMe+.
|
|
287
|
+
#
|
|
288
|
+
# vendor.name is operator-supplied data interpolated into an HTML parse_mode message
|
|
289
|
+
# (TelegramMessageFactory#header wraps the title in <b>), so it MUST be escaped —
|
|
290
|
+
# Telegram rejects unescaped &, < and > with "400 can't parse entities", and
|
|
291
|
+
# TelegramNotificationSender swallows that error, so the alert would vanish silently.
|
|
292
|
+
def vendor_alert_title(vendor)
|
|
293
|
+
return '🎫 --- [NEW ORDER FROM BOOKME+] ---' unless vendor.tenant_present?
|
|
294
|
+
|
|
295
|
+
"🎫 --- [NEW ORDER FROM #{CGI.escapeHTML(vendor.name.to_s)}] ---"
|
|
296
|
+
end
|
|
297
|
+
|
|
283
298
|
def notify_order_complete_app_notification_to_user
|
|
284
299
|
return if products.all?(&:vote_package?) # disable app push notification for vote package orders
|
|
285
300
|
|
|
@@ -5,11 +5,22 @@ module SpreeCmCommissioner
|
|
|
5
5
|
included do
|
|
6
6
|
has_one :ticket_transfer, class_name: 'SpreeCmCommissioner::TicketTransfer'
|
|
7
7
|
|
|
8
|
-
scope :not_ticket_transfer, -> { where.not(channel: SpreeCmCommissioner::
|
|
8
|
+
scope :not_ticket_transfer, -> { where.not(channel: SpreeCmCommissioner::OrderChannelBitwise::TICKET_TRANSFER_CHANNEL) }
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def ticket_transfer?
|
|
12
|
-
channel == SpreeCmCommissioner::
|
|
12
|
+
channel == SpreeCmCommissioner::OrderChannelBitwise::TICKET_TRANSFER_CHANNEL
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Completes an accepted ticket transfer for this order. Called from
|
|
16
|
+
# Spree::Payment#after_completed, so the buyer's payment is already captured.
|
|
17
|
+
def complete_ticket_transfer_if_ready
|
|
18
|
+
return unless ticket_transfer?
|
|
19
|
+
|
|
20
|
+
transfer = ticket_transfer
|
|
21
|
+
return if transfer.blank? || !transfer.accepted?
|
|
22
|
+
|
|
23
|
+
SpreeCmCommissioner::TicketTransfers::Complete.call(ticket_transfer: transfer)
|
|
13
24
|
end
|
|
14
25
|
end
|
|
15
26
|
end
|
|
@@ -8,6 +8,25 @@ module SpreeCmCommissioner
|
|
|
8
8
|
preference :telegram_chat_id, :string
|
|
9
9
|
preference :telegram_chat_name, :string
|
|
10
10
|
preference :telegram_chat_type, :string
|
|
11
|
+
preference :telegram_alert_channels, :integer, default: 1
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Should this vendor's Telegram group be alerted for an order on this channel?
|
|
15
|
+
# An unmapped channel alerts — a stray message is a better failure than silence.
|
|
16
|
+
def alerts_telegram_for_channel?(channel_key)
|
|
17
|
+
bit = SpreeCmCommissioner::OrderChannelBitwise::CHANNEL_ALERT_BITS[channel_key]
|
|
18
|
+
return true if bit.nil?
|
|
19
|
+
|
|
20
|
+
telegram_alert_channels_mask.anybits?(bit)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Spree's getter is `preferences.fetch(name) { default }`, which falls back only when the
|
|
24
|
+
# key is ABSENT. A key present with a nil value returns nil, and nil.to_i == 0 would mute
|
|
25
|
+
# EVERY channel silently. Fall back to the default instead; a deliberate 0 survives
|
|
26
|
+
# because 0 is truthy in Ruby.
|
|
27
|
+
def telegram_alert_channels_mask
|
|
28
|
+
(preferred_telegram_alert_channels ||
|
|
29
|
+
SpreeCmCommissioner::OrderChannelBitwise::DEFAULT_TELEGRAM_ALERT_CHANNELS).to_i
|
|
11
30
|
end
|
|
12
31
|
end
|
|
13
32
|
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# The fraud_config/voting_config JSON stores and their derived, show-inheriting accessors.
|
|
3
|
+
# Extracted purely to keep VotingSession under RuboCop's Metrics/ClassLength — no behavior
|
|
4
|
+
# here depends on anything else in the concern split (unlike VotingSessionTimingConcern, whose
|
|
5
|
+
# enum/predicate ordering matters).
|
|
6
|
+
module VotingSessionConfigConcern
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
store_accessor :fraud_config,
|
|
11
|
+
:max_votes_per_minute_per_user,
|
|
12
|
+
:max_votes_per_minute_per_ip,
|
|
13
|
+
:max_accounts_per_device,
|
|
14
|
+
:block_vpn,
|
|
15
|
+
:max_votes_per_contestant_per_user
|
|
16
|
+
|
|
17
|
+
store_accessor :voting_config,
|
|
18
|
+
:free_vote_limit,
|
|
19
|
+
:free_vote_limit_type,
|
|
20
|
+
:allow_user_to_get_free_vote,
|
|
21
|
+
:paid_vote_enabled,
|
|
22
|
+
:max_paid_votes_per_session,
|
|
23
|
+
:sms_vote_enabled,
|
|
24
|
+
:sms_vote_cost_usd,
|
|
25
|
+
:operator_vote_enabled,
|
|
26
|
+
:votes_weight,
|
|
27
|
+
:voters_weight,
|
|
28
|
+
:suggestion_top_n,
|
|
29
|
+
:pre_vote_weight
|
|
30
|
+
|
|
31
|
+
validate :weights_present_for_weighted_sum
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
DEFAULT_SUGGESTION_TOP_N = 1
|
|
35
|
+
|
|
36
|
+
def effective_suggestion_top_n
|
|
37
|
+
suggestion_top_n&.to_i&.positive? ? suggestion_top_n.to_i : DEFAULT_SUGGESTION_TOP_N
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def effective_voting_config
|
|
41
|
+
return {} unless show
|
|
42
|
+
|
|
43
|
+
config = parse_json_config(voting_config)
|
|
44
|
+
show_config = show.effective_voting_config
|
|
45
|
+
config.reverse_merge(show_config || {})
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def effective_fraud_config
|
|
49
|
+
return {} unless show
|
|
50
|
+
|
|
51
|
+
config = parse_json_config(fraud_config)
|
|
52
|
+
show_config = show.effective_fraud_config
|
|
53
|
+
config.reverse_merge(show_config || {})
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def max_votes_per_user
|
|
57
|
+
effective_voting_config['max_votes_per_user'] || 10
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def rate_limit_window
|
|
61
|
+
effective_voting_config['rate_limit_window'] || 60
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def rate_limit_max_votes
|
|
65
|
+
effective_voting_config['rate_limit_max_votes'] || 5
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def weights_present_for_weighted_sum
|
|
71
|
+
return unless weighted_sum?
|
|
72
|
+
|
|
73
|
+
config = effective_voting_config
|
|
74
|
+
errors.add(:votes_weight, :blank) if config['votes_weight'].blank?
|
|
75
|
+
errors.add(:voters_weight, :blank) if config['voters_weight'].blank?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse_json_config(value)
|
|
79
|
+
return {} if value.nil? || value == ''
|
|
80
|
+
return value if value.is_a?(Hash)
|
|
81
|
+
|
|
82
|
+
JSON.parse(value)
|
|
83
|
+
rescue JSON::ParserError
|
|
84
|
+
{}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# How a voting session's vote window (vote_open_at/vote_close_at) gets its values, kept
|
|
3
|
+
# deliberately separate from opens_at/closes_at — the show's own time slot within its episode
|
|
4
|
+
# (an episode can span many voting sessions, e.g. one per day of a week-long run), which has
|
|
5
|
+
# nothing to do with when voting is actually open.
|
|
6
|
+
#
|
|
7
|
+
# There is no mode flag: the presence of vote_open_at/vote_close_at is the signal.
|
|
8
|
+
#
|
|
9
|
+
# Pre-set at creation — auto-activates: once #activate flips status to enabled, can_vote?
|
|
10
|
+
# just compares Time.current against the already-real timestamps.
|
|
11
|
+
# Left blank — #start_vote! rewrites them to now / now + vote_duration_seconds the
|
|
12
|
+
# moment the organizer presses it, live during the broadcast.
|
|
13
|
+
#
|
|
14
|
+
# Both paths start at status: archived and reach enabled through one of two routes:
|
|
15
|
+
#
|
|
16
|
+
# archived --(check "Visible")--> on_air --#start_vote!--> enabled (progressive reveal, then vote)
|
|
17
|
+
# archived ------------------------------#start_vote!--> enabled (reveal and vote in one click)
|
|
18
|
+
#
|
|
19
|
+
# There is no separate "reveal" action: setting visible: true while archived (from the organizer
|
|
20
|
+
# dashboard's edit form) *is* the reveal — see #promote_from_archived_when_made_visible, which
|
|
21
|
+
# auto-transitions status to on_air in the same write rather than rejecting the attempt. on_air
|
|
22
|
+
# exists specifically so a session can show its contestants one by one — in sync with a live
|
|
23
|
+
# performance — before voting opens; it is visible but never votable (status isn't enabled, so
|
|
24
|
+
# can_vote? is false regardless of vote_open_at).
|
|
25
|
+
module VotingSessionTimingConcern
|
|
26
|
+
extend ActiveSupport::Concern
|
|
27
|
+
|
|
28
|
+
included do
|
|
29
|
+
validates :vote_duration_seconds, numericality: { greater_than: 0 }, allow_nil: true
|
|
30
|
+
validate :vote_closes_after_vote_opens
|
|
31
|
+
|
|
32
|
+
# before_update, not before_save: the `visible` column defaults to true at the DB level, so
|
|
33
|
+
# a brand-new record saved with the default status: archived (see
|
|
34
|
+
# ParamsConcern#force_archived_status_on_create, which explicitly sets visible: false too)
|
|
35
|
+
# must never auto-promote itself on its first save just because it hasn't touched `visible`
|
|
36
|
+
# yet — "reveal" is something an organizer does to an *existing* archived session.
|
|
37
|
+
before_update :promote_from_archived_when_made_visible
|
|
38
|
+
after_commit :publish_live_state_to_firestore, on: %i[create update], if: :live_state_changed?
|
|
39
|
+
|
|
40
|
+
scope :visible, -> { where(visible: true) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Columns the show screen renders. `display` is included because hiding a session should
|
|
44
|
+
# disappear from the app as promptly as opening one appears.
|
|
45
|
+
LIVE_STATE_COLUMNS = %w[status opens_at closes_at vote_open_at vote_close_at display visible].freeze
|
|
46
|
+
|
|
47
|
+
# Opens the vote window *now* rather than at a pre-set time, overriding whatever vote_open_at
|
|
48
|
+
# already held (a live show running early is exactly when this gets pressed). visible: true is
|
|
49
|
+
# set explicitly so this also works when called directly from archived — revealing and opening
|
|
50
|
+
# voting in one click, without going through on_air first.
|
|
51
|
+
def start_vote!
|
|
52
|
+
now = Time.current
|
|
53
|
+
update!(
|
|
54
|
+
vote_open_at: now,
|
|
55
|
+
vote_close_at: now + vote_duration_seconds.to_i.seconds,
|
|
56
|
+
status: :enabled,
|
|
57
|
+
visible: true
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
SpreeCmCommissioner::VotingSessionAutoCloseJob.set(wait_until: vote_close_at).perform_later(id)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Ends the vote window immediately and moves to closed. status: closed (not just a rewritten
|
|
64
|
+
# vote_close_at) matters for two reasons: votes are rejected the instant this runs, since
|
|
65
|
+
# can_vote? short-circuits on enabled? before ever comparing timestamps — no dependency on the
|
|
66
|
+
# inclusive Time#between? boundary at vote_close_at; and it's what triggers
|
|
67
|
+
# schedule_counter_reconciliation (only fires on saved_change_to_status?(enabled -> closed)),
|
|
68
|
+
# which would otherwise only run via the separate "Close" action, never on a manual stop.
|
|
69
|
+
#
|
|
70
|
+
# vote_close_at clamped to vote_open_at + 1s: an organizer who starts and immediately stops
|
|
71
|
+
# would otherwise produce vote_close_at == vote_open_at, which the closes-after-opens
|
|
72
|
+
# validation rejects — a validation error on what is a legitimate "cancel that" action.
|
|
73
|
+
def stop_vote!
|
|
74
|
+
opened_at = vote_open_at || Time.current
|
|
75
|
+
update!(status: :closed, vote_close_at: [Time.current, opened_at + 1.second].max)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# True when voting hasn't opened yet — either vote_open_at was never set, it's set but its
|
|
79
|
+
# time hasn't arrived, or status is on_air. The on_air check matters on its own: on_air can be
|
|
80
|
+
# revisited after a prior round already closed (an organizer reverting status by hand to
|
|
81
|
+
# re-reveal), in which case vote_open_at/vote_close_at are still populated from that earlier
|
|
82
|
+
# round and both in the past — without this, the timestamp check alone would say the window
|
|
83
|
+
# isn't pending, and Firestore's state_for would wrongly report 'closed' for a session that's
|
|
84
|
+
# actually on_air right now. Covers "never started," "pre-scheduled but not live yet," and
|
|
85
|
+
# "on_air again after a previous round" identically, since none of them is votable nor has a
|
|
86
|
+
# real countdown to show.
|
|
87
|
+
def vote_window_pending?
|
|
88
|
+
on_air? || vote_open_at.blank? || Time.current < vote_open_at
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Shifts vote_close_at by `seconds` (organizer-chosen at the moment, not necessarily
|
|
92
|
+
# vote_duration_seconds) without touching vote_open_at — distinct from #start_vote!, which
|
|
93
|
+
# would discard the time already elapsed by resetting the window to start now.
|
|
94
|
+
#
|
|
95
|
+
# Extends from max(vote_close_at, Time.current), not from the raw stored value: the
|
|
96
|
+
# controller also offers this once a window has already lapsed (status still enabled, nothing
|
|
97
|
+
# auto-flipped it to closed yet) specifically to reopen it. Adding straight to a stale
|
|
98
|
+
# vote_close_at sitting hours in the past would land the new value in the past too — "+10
|
|
99
|
+
# minutes" on a session that closed last night must mean 10 minutes from now, not 10 minutes
|
|
100
|
+
# from when it closed.
|
|
101
|
+
#
|
|
102
|
+
# `seconds` may be negative to cut a currently-open window short without a full Stop Vote;
|
|
103
|
+
# going negative enough to land at or before vote_open_at raises via the closes-after-opens
|
|
104
|
+
# validation.
|
|
105
|
+
def extend_vote!(seconds)
|
|
106
|
+
update!(vote_close_at: [vote_close_at, Time.current].max + seconds.to_i.seconds)
|
|
107
|
+
|
|
108
|
+
SpreeCmCommissioner::VotingSessionAutoCloseJob.set(wait_until: vote_close_at).perform_later(id)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# archived sessions are prepped privately by default (visible starts false), but the organizer
|
|
114
|
+
# controls visibility directly from the edit form — checking "Visible to Viewers" while
|
|
115
|
+
# archived is the reveal action, not a separate button. Rather than rejecting that write, treat
|
|
116
|
+
# it as intent: promote status to on_air in the same save, so the session actually becomes
|
|
117
|
+
# reachable via the `current` scope (which never includes plain `archived`, regardless of
|
|
118
|
+
# `visible`). Setting visible: false while archived is a no-op — status simply stays archived.
|
|
119
|
+
def promote_from_archived_when_made_visible
|
|
120
|
+
self.status = 'on_air' if archived? && visible?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def vote_closes_after_vote_opens
|
|
124
|
+
return unless vote_open_at && vote_close_at
|
|
125
|
+
|
|
126
|
+
errors.add(:vote_close_at, :must_be_after_vote_opens_at) if vote_close_at <= vote_open_at
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def live_state_changed?
|
|
130
|
+
LIVE_STATE_COLUMNS.any? { |column| saved_change_to_attribute?(column) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def publish_live_state_to_firestore
|
|
134
|
+
SpreeCmCommissioner::VotingSessionFirestorePublishJob.perform_later(id)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|