spree_cm_commissioner 2.9.6 → 2.9.7
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/.env.example +3 -8
- data/Gemfile.lock +1 -1
- data/app/controllers/spree/admin/users_controller_decorator.rb +18 -0
- data/app/controllers/spree/api/v2/storefront/telegram_oauth_tokens_controller.rb +35 -2
- data/app/controllers/spree/api/v2/tenant/order_histories_controller.rb +2 -0
- data/app/finders/spree_cm_commissioner/routes/find.rb +1 -1
- data/app/interactors/spree_cm_commissioner/telegram_oauth_id_token_provider.rb +3 -9
- data/app/interactors/spree_cm_commissioner/telegram_oauth_token_exchanger.rb +5 -5
- data/app/interactors/spree_cm_commissioner/user_telegram_oauth_authenticator.rb +11 -1
- data/app/mailers/spree/order_mailer_decorator.rb +1 -0
- data/app/models/concerns/spree_cm_commissioner/event_metadata.rb +12 -2
- data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +2 -0
- data/app/models/concerns/spree_cm_commissioner/store_preference.rb +2 -0
- data/app/models/concerns/spree_cm_commissioner/tenant_preference.rb +2 -0
- data/app/models/spree_cm_commissioner/seat_layout.rb +12 -4
- data/app/models/spree_cm_commissioner/show.rb +3 -1
- data/app/models/spree_cm_commissioner/show_episode.rb +3 -1
- data/app/models/spree_cm_commissioner/trip.rb +5 -0
- data/app/models/spree_cm_commissioner/vehicle_type.rb +1 -0
- data/app/overrides/spree/admin/stores/_form/telegram_auth.html.erb.deface +73 -0
- data/app/queries/spree_cm_commissioner/multi_leg_trips_query.rb +5 -0
- data/app/queries/spree_cm_commissioner/single_leg_trips_query.rb +2 -1
- data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +1 -0
- data/app/services/spree_cm_commissioner/voting_leaderboards/time_range_result.rb +79 -0
- data/app/views/spree/admin/tenants/_form.html.erb +27 -0
- data/app/views/spree/admin/tenants/form/_telegram_auth.html.erb +61 -0
- data/config/initializers/spree_permitted_attributes.rb +2 -0
- data/config/locales/en.yml +2 -0
- data/config/locales/km.yml +3 -0
- data/db/migrate/20260729000000_add_visibility_to_cm_trips.rb +6 -0
- data/db/migrate/20260729000001_add_session_created_at_index_to_cm_votes.rb +12 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e267212ae81075d66a95e65c2eb320ea05bc9b18f53b1bc4a74896639ddd4ed2
|
|
4
|
+
data.tar.gz: cc28b8d31dd89f481864b6f5c366d264335403ca4f8aaf9a00dbd39e502012b6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7cb85b5d8aaac69dc845ddeb068ec96200cf400a7c6d9f3bd979681fe4d03dd08cf4358a059855825785d9524e1a4d04e0447e3b0bfc682f4827c1b09734fa7d
|
|
7
|
+
data.tar.gz: 5727d1541df27c51c58ef2b4a7e022096c62aa6666fcb58514dbe39621a44395478d1f5ea5ecef24280767c0d41f47e43f823276720ce63f45c7ef371278b0a8
|
data/.env.example
CHANGED
|
@@ -32,14 +32,9 @@ WAITING_ROOM_MAX_ETA_BATCH_SIZE=50 # cap on batch size assumed when estimating t
|
|
|
32
32
|
VATTANAC_AES_SECRET_KEY= ""
|
|
33
33
|
VATTANAC_PUBLIC_KEY=""
|
|
34
34
|
|
|
35
|
-
# Telegram OAuth (OpenID Connect) login
|
|
36
|
-
#
|
|
37
|
-
# Telegram
|
|
38
|
-
# app/interactors/spree_cm_commissioner/telegram_oauth_id_token_provider.rb
|
|
39
|
-
TELEGRAM_OAUTH_CLIENT_ID=""
|
|
40
|
-
# client_secret injected server-side when proxying the PKCE code -> id_token
|
|
41
|
-
# exchange in app/interactors/spree_cm_commissioner/telegram_oauth_token_exchanger.rb
|
|
42
|
-
TELEGRAM_OAUTH_CLIENT_SECRET=""
|
|
35
|
+
# Telegram OAuth (OpenID Connect) login client_id/client_secret are configured
|
|
36
|
+
# per tenant (or on the default store for tenant-less logins) from the admin
|
|
37
|
+
# under Telegram Authentication - see docs/authentication/telegram-login.md.
|
|
43
38
|
|
|
44
39
|
# Organizer URL
|
|
45
40
|
ORGANIZER_URL=http://127.0.0.1:4000/organizer
|
data/Gemfile.lock
CHANGED
|
@@ -20,8 +20,26 @@ module Spree
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# override
|
|
24
|
+
def update
|
|
25
|
+
preserve_non_form_roles
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
|
|
23
29
|
private
|
|
24
30
|
|
|
31
|
+
# Role types the user form doesn't render (assigned in organizer dashboards), e.g. check-in groups and preview.
|
|
32
|
+
NON_FORM_ROLE_TYPES = %i[check_in preview].freeze
|
|
33
|
+
|
|
34
|
+
# spree_role_ids= replaces the whole role set, so a submit that omits these would drop them. Merge
|
|
35
|
+
# the user's existing ones back in so they survive. Skip when the form didn't submit roles at all.
|
|
36
|
+
def preserve_non_form_roles
|
|
37
|
+
return if params.dig(:user, :spree_role_ids).nil?
|
|
38
|
+
|
|
39
|
+
preserved_ids = @user.spree_roles.where(role_type: NON_FORM_ROLE_TYPES).pluck(:id).map(&:to_s)
|
|
40
|
+
params[:user][:spree_role_ids] = (params[:user][:spree_role_ids] + preserved_ids).uniq
|
|
41
|
+
end
|
|
42
|
+
|
|
25
43
|
def build_profile
|
|
26
44
|
return unless permitted_resource_params[:profile]
|
|
27
45
|
|
|
@@ -9,7 +9,11 @@ module Spree
|
|
|
9
9
|
# has no access token yet (this is part of logging in).
|
|
10
10
|
class TelegramOauthTokensController < Spree::Api::V2::BaseController
|
|
11
11
|
def create
|
|
12
|
-
|
|
12
|
+
return render_error_payload(credential_mismatch_message) if credential_mismatch_message
|
|
13
|
+
|
|
14
|
+
result = SpreeCmCommissioner::TelegramOauthTokenExchanger.call(
|
|
15
|
+
exchange_params.merge(client_secret: resolved_client_secret)
|
|
16
|
+
)
|
|
13
17
|
|
|
14
18
|
return render_error_payload(result.message) unless result.success?
|
|
15
19
|
|
|
@@ -19,7 +23,36 @@ module Spree
|
|
|
19
23
|
private
|
|
20
24
|
|
|
21
25
|
def exchange_params
|
|
22
|
-
params.permit(:client_id, :code, :redirect_uri, :code_verifier)
|
|
26
|
+
params.permit(:client_id, :code, :redirect_uri, :code_verifier).to_h.symbolize_keys
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# `tenant_id` scopes which tenant's bot this login belongs to; with none
|
|
30
|
+
# given, the default store's bot is used. Distinguished from
|
|
31
|
+
# telegram_oauth_client_secret_not_configured (raised by the exchanger
|
|
32
|
+
# when the owner has no secret set) so a caller can tell "wrong
|
|
33
|
+
# tenant/client_id" apart from "correct tenant, nothing configured yet".
|
|
34
|
+
def credential_mismatch_message
|
|
35
|
+
return 'telegram_oauth_tenant_not_found' if params[:tenant_id].present? && credential_owner.nil?
|
|
36
|
+
return nil if credential_owner.nil? || credential_owner.preferred_telegram_oauth_client_id.blank?
|
|
37
|
+
return 'telegram_oauth_client_id_mismatch' unless client_id_matches_owner?
|
|
38
|
+
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def client_id_matches_owner?
|
|
43
|
+
credential_owner.preferred_telegram_oauth_client_id == exchange_params[:client_id]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resolved_client_secret
|
|
47
|
+
credential_owner&.preferred_telegram_oauth_client_secret.presence
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def credential_owner
|
|
51
|
+
@credential_owner ||= if params[:tenant_id].present?
|
|
52
|
+
SpreeCmCommissioner::Tenant.find_by(id: params[:tenant_id])
|
|
53
|
+
else
|
|
54
|
+
Spree::Store.default
|
|
55
|
+
end
|
|
23
56
|
end
|
|
24
57
|
end
|
|
25
58
|
end
|
|
@@ -43,6 +43,7 @@ module Spree
|
|
|
43
43
|
# It related to user cancel or complete order flow, which relies on the most recent orders being returned first.
|
|
44
44
|
if spree_current_user.present?
|
|
45
45
|
spree_current_user.orders
|
|
46
|
+
.exclude_vote_package
|
|
46
47
|
.not_archived
|
|
47
48
|
.incomplete
|
|
48
49
|
.order(id: :desc)
|
|
@@ -51,6 +52,7 @@ module Spree
|
|
|
51
52
|
return Spree::Order.none if order_tokens.empty?
|
|
52
53
|
|
|
53
54
|
Spree::Order.not_archived
|
|
55
|
+
.exclude_vote_package
|
|
54
56
|
.incomplete
|
|
55
57
|
.without_user
|
|
56
58
|
.where(token: order_tokens)
|
|
@@ -21,7 +21,7 @@ module SpreeCmCommissioner
|
|
|
21
21
|
@destination_place_id = @params.dig(:filter, :destination_place_id)
|
|
22
22
|
@query = @params.dig(:filter, :query) || @params[:query]
|
|
23
23
|
@limit = (@params[:limit] || @params.dig(:page, :limit) || 50).to_i
|
|
24
|
-
@order = @params[:order] || 'created_at
|
|
24
|
+
@order = @params[:order] || 'created_at ASC'
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def execute
|
|
@@ -15,14 +15,12 @@ module SpreeCmCommissioner
|
|
|
15
15
|
# "oidc-1") by default.
|
|
16
16
|
SUPPORTED_ALGORITHMS = %w[RS256 ES256].freeze
|
|
17
17
|
|
|
18
|
-
# :id_token
|
|
18
|
+
# :id_token, :client_id
|
|
19
19
|
def call
|
|
20
20
|
context.fail!(message: 'telegram_id_token_missing') if context.id_token.blank?
|
|
21
|
+
context.fail!(message: 'telegram_oauth_client_id_not_configured') if context.client_id.blank?
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
context.fail!(message: 'telegram_oauth_client_id_not_configured') if audience.blank?
|
|
24
|
-
|
|
25
|
-
claim = decode_id_token(audience)
|
|
23
|
+
claim = decode_id_token(context.client_id)
|
|
26
24
|
return if claim.nil?
|
|
27
25
|
|
|
28
26
|
context.claim = claim
|
|
@@ -83,10 +81,6 @@ module SpreeCmCommissioner
|
|
|
83
81
|
context.fail!(message: 'telegram_jwks_fetch_failed', error: e.message)
|
|
84
82
|
end
|
|
85
83
|
|
|
86
|
-
def expected_audience
|
|
87
|
-
ENV['TELEGRAM_OAUTH_CLIENT_ID'].presence
|
|
88
|
-
end
|
|
89
|
-
|
|
90
84
|
# sub is the Telegram user id (also the chat id once the user has /started
|
|
91
85
|
# the bot), which keeps OIDC logins consistent with the Telegram Web App
|
|
92
86
|
# flow that also keys UserIdentityProvider.telegram on the Telegram user id.
|
|
@@ -12,13 +12,17 @@ module SpreeCmCommissioner
|
|
|
12
12
|
# still requires `client_secret`, which is why it must be injected here
|
|
13
13
|
# server-side rather than sent by the app.
|
|
14
14
|
#
|
|
15
|
+
# `client_secret` is resolved by the caller (which has tenant context from
|
|
16
|
+
# the request) and passed in like any other param - this interactor only
|
|
17
|
+
# forwards it to Telegram, it never looks up a tenant/store itself.
|
|
18
|
+
#
|
|
15
19
|
# Docs: https://core.telegram.org/bots/telegram-login
|
|
16
20
|
class TelegramOauthTokenExchanger < BaseInteractor
|
|
17
21
|
TOKEN_ENDPOINT = 'https://oauth.telegram.org/token'.freeze
|
|
18
22
|
|
|
19
23
|
REQUIRED_PARAMS = %i[client_id code redirect_uri code_verifier].freeze
|
|
20
24
|
|
|
21
|
-
delegate :client_id, :code, :redirect_uri, :code_verifier, to: :context
|
|
25
|
+
delegate :client_id, :client_secret, :code, :redirect_uri, :code_verifier, to: :context
|
|
22
26
|
|
|
23
27
|
def call
|
|
24
28
|
missing = REQUIRED_PARAMS.find { |key| context[key].blank? }
|
|
@@ -33,10 +37,6 @@ module SpreeCmCommissioner
|
|
|
33
37
|
|
|
34
38
|
private
|
|
35
39
|
|
|
36
|
-
def client_secret
|
|
37
|
-
ENV['TELEGRAM_OAUTH_CLIENT_SECRET'].presence
|
|
38
|
-
end
|
|
39
|
-
|
|
40
40
|
def post_to_telegram
|
|
41
41
|
uri = URI(TOKEN_ENDPOINT)
|
|
42
42
|
request = Net::HTTP::Post.new(uri)
|
|
@@ -19,12 +19,22 @@ module SpreeCmCommissioner
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
21
|
def verified_provider
|
|
22
|
-
result = SpreeCmCommissioner::TelegramOauthIdTokenProvider.call(id_token: id_token)
|
|
22
|
+
result = SpreeCmCommissioner::TelegramOauthIdTokenProvider.call(id_token: id_token, client_id: telegram_client_id)
|
|
23
23
|
context.fail!(message: result.message) unless result.success?
|
|
24
24
|
|
|
25
25
|
result.provider
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Each tenant runs its own Telegram bot, so the audience an id_token must
|
|
29
|
+
# carry is looked up on the tenant directly - no default-store or ENV
|
|
30
|
+
# fallback, so a misconfigured tenant fails loudly instead of silently
|
|
31
|
+
# authenticating against someone else's bot.
|
|
32
|
+
def telegram_client_id
|
|
33
|
+
return Spree::Store.default.preferred_telegram_oauth_client_id if tenant_id.blank?
|
|
34
|
+
|
|
35
|
+
SpreeCmCommissioner::Tenant.find_by(id: tenant_id)&.preferred_telegram_oauth_client_id
|
|
36
|
+
end
|
|
37
|
+
|
|
28
38
|
def find_or_register_user(provider)
|
|
29
39
|
identity_checker = SpreeCmCommissioner::UserIdentityChecker.call(
|
|
30
40
|
identity_type: provider[:identity_type],
|
|
@@ -3,6 +3,7 @@ module Spree
|
|
|
3
3
|
def cancel_email(order, resend: false)
|
|
4
4
|
@order = order.respond_to?(:id) ? order : Spree::Order.find(order)
|
|
5
5
|
return false if @order.email.blank?
|
|
6
|
+
return false if @order.products.all?(&:vote_package?) # disable cancel email for vote package orders
|
|
6
7
|
|
|
7
8
|
setup_tenant_and_store
|
|
8
9
|
if @tenant.present?
|
|
@@ -21,15 +21,25 @@ module SpreeCmCommissioner
|
|
|
21
21
|
# ⚠️ CRITICAL: Cached seat layout IDs kept in sync by SeatLayout callback.
|
|
22
22
|
# If callback is removed/modified without updating this, availability checks will fail.
|
|
23
23
|
#
|
|
24
|
-
#
|
|
24
|
+
# `preload_seat_layout_ids` is every seat layout ever attached to this event, regardless of
|
|
25
|
+
# status — kept for callers that need the full history/count. `preload_active_seat_layout_ids`
|
|
26
|
+
# is the subset with status `active`, so consumers checking "can this still be booked?"
|
|
27
|
+
# (e.g. hasSeatSelection on the app) should use that one instead — deactivating/archiving a
|
|
28
|
+
# layout removes it from this list even though it stays in `preload_seat_layout_ids`.
|
|
29
|
+
#
|
|
30
|
+
# Sync callback (see seat_layout.rb:L32-44):
|
|
25
31
|
# after_commit :sync_seat_layout_id_to_layoutable!, if: -> { layoutable.present? }
|
|
26
32
|
# def sync_seat_layout_id_to_layoutable!
|
|
27
|
-
# layoutable.update!(
|
|
33
|
+
# layoutable.update!(
|
|
34
|
+
# preload_seat_layout_ids: layoutable.seat_layouts.pluck(:id),
|
|
35
|
+
# preload_active_seat_layout_ids: layoutable.seat_layouts.active.pluck(:id)
|
|
36
|
+
# )
|
|
28
37
|
# end
|
|
29
38
|
#
|
|
30
39
|
# TODO: Not scalable for many seat layouts. Consider storing boolean flag
|
|
31
40
|
# (has_active_seat_layout) instead when seat layouts become frequent.
|
|
32
41
|
store_public_metadata :preload_seat_layout_ids, :array, default: []
|
|
42
|
+
store_public_metadata :preload_active_seat_layout_ids, :array, default: []
|
|
33
43
|
|
|
34
44
|
before_validation :validate_at_least_one_check_in_flow_presence
|
|
35
45
|
end
|
|
@@ -281,6 +281,8 @@ module SpreeCmCommissioner
|
|
|
281
281
|
end
|
|
282
282
|
|
|
283
283
|
def notify_order_complete_app_notification_to_user
|
|
284
|
+
return if products.all?(&:vote_package?) # disable app push notification for vote package orders
|
|
285
|
+
|
|
284
286
|
SpreeCmCommissioner::OrderCompleteNotificationSender.call(order: self)
|
|
285
287
|
end
|
|
286
288
|
|
|
@@ -11,6 +11,8 @@ module SpreeCmCommissioner
|
|
|
11
11
|
preference :telegram_order_anomaly_alert_chat_id, :string
|
|
12
12
|
preference :assetlinks, :string, default: ''
|
|
13
13
|
preference :apple_app_site_association, :string, default: ''
|
|
14
|
+
preference :telegram_oauth_client_id, :string, default: ''
|
|
15
|
+
preference :telegram_oauth_client_secret, :string, default: ''
|
|
14
16
|
end
|
|
15
17
|
end
|
|
16
18
|
end
|
|
@@ -15,6 +15,8 @@ module SpreeCmCommissioner
|
|
|
15
15
|
preference :facebook, :string, default: ''
|
|
16
16
|
preference :twitter, :string, default: ''
|
|
17
17
|
preference :instagram, :string, default: ''
|
|
18
|
+
preference :telegram_oauth_client_id, :string, default: ''
|
|
19
|
+
preference :telegram_oauth_client_secret, :string, default: ''
|
|
18
20
|
end
|
|
19
21
|
end
|
|
20
22
|
end
|
|
@@ -33,11 +33,19 @@ module SpreeCmCommissioner
|
|
|
33
33
|
# Update layoutable's seat layout metadata after any change (create/update/destroy).
|
|
34
34
|
# This keeps the cache in sync with the actual seat_layouts association.
|
|
35
35
|
if layoutable.respond_to?(:seat_layouts)
|
|
36
|
-
# Event has many seat layouts:
|
|
37
|
-
|
|
36
|
+
# Event has many seat layouts: `..._ids` caches every layout ever attached (any status);
|
|
37
|
+
# `active_..._ids` caches only the `active` ones, for callers checking bookability.
|
|
38
|
+
layoutable.update!(
|
|
39
|
+
preload_seat_layout_ids: layoutable.seat_layouts.pluck(:id),
|
|
40
|
+
preload_active_seat_layout_ids: layoutable.seat_layouts.active.pluck(:id)
|
|
41
|
+
)
|
|
38
42
|
elsif layoutable.respond_to?(:seat_layout)
|
|
39
|
-
# Taxon/Vehicle has single seat layout: cache the ID (or nil if destroyed)
|
|
40
|
-
|
|
43
|
+
# Taxon/Vehicle has single seat layout: cache the ID (or nil if destroyed); the active
|
|
44
|
+
# variant is also nil while the layout is inactive/archived, not just when destroyed.
|
|
45
|
+
layoutable.update!(
|
|
46
|
+
preload_seat_layout_id: destroyed? ? nil : id,
|
|
47
|
+
preload_active_seat_layout_id: destroyed? || !active? ? nil : id
|
|
48
|
+
)
|
|
41
49
|
end
|
|
42
50
|
end
|
|
43
51
|
end
|
|
@@ -13,7 +13,9 @@ module SpreeCmCommissioner
|
|
|
13
13
|
has_many :vote_packages, -> { where(product_type: :vote_package) }, class_name: 'SpreeCmCommissioner::ShowEpisode', foreign_key: :event_id
|
|
14
14
|
has_many :voting_sessions, through: :episodes, class_name: 'SpreeCmCommissioner::VotingSession'
|
|
15
15
|
|
|
16
|
-
has_one :current_episode,
|
|
16
|
+
has_one :current_episode, lambda {
|
|
17
|
+
current_or_next_upcoming.where(status: :active)
|
|
18
|
+
}, class_name: 'SpreeCmCommissioner::ShowEpisode', foreign_key: :event_id
|
|
17
19
|
has_one :current_voting_session, through: :current_episode
|
|
18
20
|
|
|
19
21
|
before_validation { self.type = self.class.name }
|
|
@@ -17,7 +17,9 @@ module SpreeCmCommissioner
|
|
|
17
17
|
primary_key: :event_id
|
|
18
18
|
has_many :contestants, through: :season, source: :show_contestants
|
|
19
19
|
|
|
20
|
-
has_one :current_voting_session,
|
|
20
|
+
has_one :current_voting_session, lambda {
|
|
21
|
+
current_or_next_upcoming.where.not(status: :disabled)
|
|
22
|
+
}, class_name: 'SpreeCmCommissioner::VotingSession', foreign_key: :episode_id
|
|
21
23
|
|
|
22
24
|
validates :ends_at, presence: true, if: -> { product_type == 'show_episode' }
|
|
23
25
|
|
|
@@ -8,10 +8,15 @@ module SpreeCmCommissioner
|
|
|
8
8
|
|
|
9
9
|
enum trip_type: { direct: 0, multi_leg: 1 }
|
|
10
10
|
|
|
11
|
+
# public: shown in trip search results; unpublic: hidden from search (kept for the owning vendor).
|
|
12
|
+
# _prefix avoids generating a bare `public?` / `Trip.public` that would collide with Ruby's `public`.
|
|
13
|
+
enum visibility: { public: 0, unpublic: 1 }, _prefix: true, _default: :public
|
|
14
|
+
|
|
11
15
|
# This model has no seat_layout column (polymorphic association), so we store the preload_seat_layout_id ID in public_metadata.
|
|
12
16
|
# This lets us check if a seat layout exists without triggering a database query.
|
|
13
17
|
# The ID is automatically updated whenever the seat_layout is saved.
|
|
14
18
|
store_public_metadata :preload_seat_layout_id, :integer
|
|
19
|
+
store_public_metadata :preload_active_seat_layout_id, :integer
|
|
15
20
|
|
|
16
21
|
# Trip: array of offsets for each leg # => [0, 1, 2]
|
|
17
22
|
# Usage: trip.preload_offset_days_array[0] # => 0
|
|
@@ -7,6 +7,7 @@ module SpreeCmCommissioner
|
|
|
7
7
|
# This lets us check if a seat layout exists without triggering a database query.
|
|
8
8
|
# The ID is automatically updated whenever the seat_layout is saved.
|
|
9
9
|
store_public_metadata :preload_seat_layout_id, :integer
|
|
10
|
+
store_public_metadata :preload_active_seat_layout_id, :integer
|
|
10
11
|
|
|
11
12
|
attr_accessor :enable_seat_layout
|
|
12
13
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<!-- insert_bottom "[data-hook='admin_store_form_fields']" -->
|
|
2
|
+
|
|
3
|
+
<% if @store.persisted? %>
|
|
4
|
+
<%
|
|
5
|
+
telegram_client_id = @store.preferred_telegram_oauth_client_id.presence
|
|
6
|
+
telegram_client_secret = @store.preferred_telegram_oauth_client_secret.presence
|
|
7
|
+
telegram_configured = telegram_client_id.present? && telegram_client_secret.present?
|
|
8
|
+
telegram_partial = telegram_client_id.present? ^ telegram_client_secret.present?
|
|
9
|
+
%>
|
|
10
|
+
<div class="card mb-4">
|
|
11
|
+
<div class="card-header d-flex flex-wrap align-items-center justify-content-between">
|
|
12
|
+
<h5 class="card-title mb-0 h6">Telegram Authentication</h5>
|
|
13
|
+
<% if telegram_configured %>
|
|
14
|
+
<span class="badge badge-success">Using this store's bot</span>
|
|
15
|
+
<% elsif telegram_partial %>
|
|
16
|
+
<span class="badge badge-warning">Incomplete</span>
|
|
17
|
+
<% else %>
|
|
18
|
+
<span class="badge badge-danger">Not configured</span>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="card-body">
|
|
22
|
+
<div id="telegram-auth-settings" data-hook class="form-group mb-0">
|
|
23
|
+
<div class="row">
|
|
24
|
+
<!-- Telegram OAuth Client ID -->
|
|
25
|
+
<div class="col-md-6">
|
|
26
|
+
<%= f.field_container :preferred_telegram_oauth_client_id do %>
|
|
27
|
+
<%= f.label :preferred_telegram_oauth_client_id, 'Client ID' %>
|
|
28
|
+
<%= f.text_field :preferred_telegram_oauth_client_id, class: 'form-control text-monospace',
|
|
29
|
+
placeholder: 'e.g. 7123456789', autocomplete: 'off' %>
|
|
30
|
+
<small class="form-text text-muted">Bot ID — digits before the colon in the BotFather token.</small>
|
|
31
|
+
<%= f.error_message_on :preferred_telegram_oauth_client_id %>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<!-- Telegram OAuth Client Secret -->
|
|
36
|
+
<div class="col-md-6">
|
|
37
|
+
<%= f.field_container :preferred_telegram_oauth_client_secret do %>
|
|
38
|
+
<%= f.label :preferred_telegram_oauth_client_secret, 'Client Secret' %>
|
|
39
|
+
<div class="input-group">
|
|
40
|
+
<%= f.password_field :preferred_telegram_oauth_client_secret,
|
|
41
|
+
class: 'form-control text-monospace', id: 'store_telegram_oauth_client_secret',
|
|
42
|
+
autocomplete: 'new-password', value: f.object.preferred_telegram_oauth_client_secret %>
|
|
43
|
+
<div class="input-group-append">
|
|
44
|
+
<button type="button" class="btn btn-outline-secondary"
|
|
45
|
+
data-telegram-secret-toggle="store_telegram_oauth_client_secret">
|
|
46
|
+
Show
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<small class="form-text text-muted">From BotFather. Server-side only — never sent to the app.</small>
|
|
51
|
+
<%= f.error_message_on :preferred_telegram_oauth_client_secret %>
|
|
52
|
+
<% end %>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<script>
|
|
60
|
+
(function () {
|
|
61
|
+
document.querySelectorAll('[data-telegram-secret-toggle]').forEach(function (button) {
|
|
62
|
+
button.addEventListener('click', function () {
|
|
63
|
+
var field = document.getElementById(button.dataset.telegramSecretToggle);
|
|
64
|
+
if (!field) return;
|
|
65
|
+
|
|
66
|
+
var hidden = field.type === 'password';
|
|
67
|
+
field.type = hidden ? 'text' : 'password';
|
|
68
|
+
button.textContent = hidden ? 'Hide' : 'Show';
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
})();
|
|
72
|
+
</script>
|
|
73
|
+
<% end %>
|
|
@@ -135,6 +135,11 @@ module SpreeCmCommissioner
|
|
|
135
135
|
scope = scope.where(parent: { route_type: SpreeCmCommissioner::Trip.route_types[route_type] }) if route_type.present?
|
|
136
136
|
scope = scope.where(parent: { vendor_id: vendor_id }) if vendor_id.present?
|
|
137
137
|
|
|
138
|
+
# Hide unpublished trips from search: filter both the leg (cm_trips) and the parent trip.
|
|
139
|
+
public_visibility = SpreeCmCommissioner::Trip.visibilities[:public]
|
|
140
|
+
scope = scope.visibility_public # filters cm_trips (the leg)
|
|
141
|
+
scope = scope.where(parent: { visibility: public_visibility })
|
|
142
|
+
|
|
138
143
|
if tenant_id.present?
|
|
139
144
|
scope = scope.joins('INNER JOIN spree_vendors v ON v.id = parent.vendor_id')
|
|
140
145
|
.where(v: { tenant_id: tenant_id })
|
|
@@ -50,7 +50,8 @@ module SpreeCmCommissioner
|
|
|
50
50
|
|
|
51
51
|
def direct_trips # rubocop:disable Metrics/MethodLength
|
|
52
52
|
# Build base conditions
|
|
53
|
-
|
|
53
|
+
# visibility: :public hides unpublished trips from search results
|
|
54
|
+
base_conditions = { trip_type: :direct, visibility: :public }
|
|
54
55
|
base_conditions[:vendor_id] = vendor_id if vendor_id.present?
|
|
55
56
|
base_conditions[:route_type] = route_type if route_type.present?
|
|
56
57
|
base_conditions[:spree_vendors] = { tenant_id: tenant_id } if tenant_id.present?
|
|
@@ -22,6 +22,7 @@ module Spree
|
|
|
22
22
|
|
|
23
23
|
# To get full seat layout details, call the seat_layouts API with this ID.
|
|
24
24
|
base.attribute :seat_layout_ids, &:preload_seat_layout_ids
|
|
25
|
+
base.attribute :active_seat_layout_ids, &:preload_active_seat_layout_ids
|
|
25
26
|
|
|
26
27
|
base.attribute :purchasable_on_app do |taxon|
|
|
27
28
|
taxon.purchasable_on == 'app' || taxon.purchasable_on == 'both'
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Aggregates vote counts for a voting session restricted to a created_at window.
|
|
4
|
+
#
|
|
5
|
+
# Redis counters and the finalized DB snapshot are both cumulative — they hold no time
|
|
6
|
+
# dimension — so any time-filtered leaderboard has to be recomputed from cm_votes.
|
|
7
|
+
# This service is the DB-backed equivalent of VoteCounters::PerContestantCounter and
|
|
8
|
+
# returns the exact same value shape so callers can swap between them.
|
|
9
|
+
#
|
|
10
|
+
# from / to are inclusive-exclusive (created_at >= from, created_at < to) so that
|
|
11
|
+
# adjacent windows never double-count a vote sitting on the boundary.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# SpreeCmCommissioner::VotingLeaderboards::TimeRangeResult.call(
|
|
15
|
+
# voting_session_id: session.id,
|
|
16
|
+
# from: 1.hour.ago,
|
|
17
|
+
# to: Time.current
|
|
18
|
+
# ).value
|
|
19
|
+
# # => { counts: { 12 => 40 }, unique_voters: 7, unique_voters_per_contestant: { 12 => 7 } }
|
|
20
|
+
module SpreeCmCommissioner
|
|
21
|
+
module VotingLeaderboards
|
|
22
|
+
class TimeRangeResult
|
|
23
|
+
prepend ::Spree::ServiceModule::Base
|
|
24
|
+
|
|
25
|
+
EMPTY_RESULT = {
|
|
26
|
+
counts: {}.freeze,
|
|
27
|
+
unique_voters: 0,
|
|
28
|
+
unique_voters_per_contestant: {}.freeze
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
def call(voting_session_id:, from: nil, to: nil)
|
|
32
|
+
return failure(nil, I18n.t('spree_cm_commissioner.voting_leaderboards.time_range_result.invalid_range')) if invalid_range?(from, to)
|
|
33
|
+
|
|
34
|
+
ids = contestant_ids(voting_session_id)
|
|
35
|
+
return success(EMPTY_RESULT) if ids.empty?
|
|
36
|
+
|
|
37
|
+
scope = votes_scope(voting_session_id, from, to)
|
|
38
|
+
|
|
39
|
+
success(
|
|
40
|
+
counts: fill(ids, scope.group(:contestant_id).sum(:quantity)),
|
|
41
|
+
unique_voters: unique_voters(scope),
|
|
42
|
+
unique_voters_per_contestant: fill(ids, unique_voters_per_contestant(scope))
|
|
43
|
+
)
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
failure(nil, e.message)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def invalid_range?(from, to)
|
|
51
|
+
from.present? && to.present? && from > to
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def contestant_ids(voting_session_id)
|
|
55
|
+
SpreeCmCommissioner::VotingContestant.where(voting_session_id: voting_session_id).pluck(:id)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def votes_scope(voting_session_id, from, to)
|
|
59
|
+
scope = SpreeCmCommissioner::Vote.where(voting_session_id: voting_session_id)
|
|
60
|
+
scope = scope.where(created_at: from..) if from.present?
|
|
61
|
+
scope = scope.where(created_at: ...to) if to.present?
|
|
62
|
+
scope
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def unique_voters(scope)
|
|
66
|
+
scope.where.not(user_id: nil).distinct.count(:user_id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def unique_voters_per_contestant(scope)
|
|
70
|
+
scope.where.not(user_id: nil).group(:contestant_id).distinct.count(:user_id)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Contestants with no votes in the window must still appear with zeros.
|
|
74
|
+
def fill(ids, rows)
|
|
75
|
+
ids.index_with { |id| rows.fetch(id, 0) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -223,6 +223,33 @@
|
|
|
223
223
|
</div>
|
|
224
224
|
</div>
|
|
225
225
|
|
|
226
|
+
<!-- ======================== Telegram Authentication ======================== -->
|
|
227
|
+
<%
|
|
228
|
+
telegram_client_id = f.object.preferred_telegram_oauth_client_id.presence
|
|
229
|
+
telegram_client_secret = f.object.preferred_telegram_oauth_client_secret.presence
|
|
230
|
+
telegram_configured = telegram_client_id.present? && telegram_client_secret.present?
|
|
231
|
+
telegram_partial = telegram_client_id.present? ^ telegram_client_secret.present?
|
|
232
|
+
%>
|
|
233
|
+
<div class="card mb-4">
|
|
234
|
+
<div class="card-body">
|
|
235
|
+
<div data-hook="admin_tenant_form_fields" class="row mt-4">
|
|
236
|
+
<div class="col-12 mb-4 d-flex flex-wrap align-items-center justify-content-between">
|
|
237
|
+
<h4 class="mb-0">Telegram Authentication <span class="text-muted" style="font-size: 1rem; font-weight: normal;">(sign customers in with this tenant's own Telegram bot)</span></h4>
|
|
238
|
+
<% if telegram_configured %>
|
|
239
|
+
<span class="badge badge-success">Using this tenant's bot</span>
|
|
240
|
+
<% elsif telegram_partial %>
|
|
241
|
+
<span class="badge badge-warning">Incomplete</span>
|
|
242
|
+
<% else %>
|
|
243
|
+
<span class="badge badge-danger">Not configured</span>
|
|
244
|
+
<% end %>
|
|
245
|
+
</div>
|
|
246
|
+
<div class="col-12">
|
|
247
|
+
<%= render partial: 'spree/admin/tenants/form/telegram_auth', locals: { f: f } %>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
226
253
|
<!-- ======================== Redirect Settings ======================== -->
|
|
227
254
|
<div class="card mb-4">
|
|
228
255
|
<div class="card-body">
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<%
|
|
2
|
+
telegram_partial = f.object.preferred_telegram_oauth_client_id.presence.present? ^
|
|
3
|
+
f.object.preferred_telegram_oauth_client_secret.presence.present?
|
|
4
|
+
%>
|
|
5
|
+
<div data-hook="admin_tenant_telegram_auth_fields">
|
|
6
|
+
<% if telegram_partial %>
|
|
7
|
+
<p class="text-warning small mb-3">
|
|
8
|
+
Both fields are required — with only one filled in, login will fail for this tenant.
|
|
9
|
+
</p>
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
12
|
+
<div class="row">
|
|
13
|
+
<div class="col-md-6">
|
|
14
|
+
<%= f.field_container :preferred_telegram_oauth_client_id do %>
|
|
15
|
+
<%= f.label :preferred_telegram_oauth_client_id, 'Client ID' %>
|
|
16
|
+
<%= f.text_field :preferred_telegram_oauth_client_id, class: 'form-control text-monospace',
|
|
17
|
+
placeholder: 'e.g. 7123456789', autocomplete: 'off' %>
|
|
18
|
+
<small class="form-text text-muted">Bot ID — digits before the colon in the BotFather token.</small>
|
|
19
|
+
<% if f.object.errors[:preferred_telegram_oauth_client_id].any? %>
|
|
20
|
+
<div class="error text-danger"><%= f.object.errors[:preferred_telegram_oauth_client_id].join(', ') %></div>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="col-md-6">
|
|
26
|
+
<%= f.field_container :preferred_telegram_oauth_client_secret do %>
|
|
27
|
+
<%= f.label :preferred_telegram_oauth_client_secret, 'Client Secret' %>
|
|
28
|
+
<div class="input-group">
|
|
29
|
+
<%= f.password_field :preferred_telegram_oauth_client_secret,
|
|
30
|
+
class: 'form-control text-monospace', id: 'tenant_telegram_oauth_client_secret',
|
|
31
|
+
autocomplete: 'new-password', value: f.object.preferred_telegram_oauth_client_secret %>
|
|
32
|
+
<div class="input-group-append">
|
|
33
|
+
<button type="button" class="btn btn-outline-secondary"
|
|
34
|
+
data-telegram-secret-toggle="tenant_telegram_oauth_client_secret">
|
|
35
|
+
Show
|
|
36
|
+
</button>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<small class="form-text text-muted">From BotFather. Server-side only — never sent to the app.</small>
|
|
40
|
+
<% if f.object.errors[:preferred_telegram_oauth_client_secret].any? %>
|
|
41
|
+
<div class="error text-danger"><%= f.object.errors[:preferred_telegram_oauth_client_secret].join(', ') %></div>
|
|
42
|
+
<% end %>
|
|
43
|
+
<% end %>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<script>
|
|
49
|
+
(function () {
|
|
50
|
+
document.querySelectorAll('[data-telegram-secret-toggle]').forEach(function (button) {
|
|
51
|
+
button.addEventListener('click', function () {
|
|
52
|
+
var field = document.getElementById(button.dataset.telegramSecretToggle);
|
|
53
|
+
if (!field) return;
|
|
54
|
+
|
|
55
|
+
var hidden = field.type === 'password';
|
|
56
|
+
field.type = hidden ? 'text' : 'password';
|
|
57
|
+
button.textContent = hidden ? 'Hide' : 'Show';
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
})();
|
|
61
|
+
</script>
|
|
@@ -71,6 +71,8 @@ module Spree
|
|
|
71
71
|
:preferred_telegram_order_anomaly_alert_chat_id,
|
|
72
72
|
:preferred_assetlinks,
|
|
73
73
|
:preferred_apple_app_site_association,
|
|
74
|
+
:preferred_telegram_oauth_client_id,
|
|
75
|
+
:preferred_telegram_oauth_client_secret,
|
|
74
76
|
{ default_notification_image_attributes: {} },
|
|
75
77
|
:term_and_condition_promotion
|
|
76
78
|
]
|
data/config/locales/en.yml
CHANGED
|
@@ -925,6 +925,8 @@ en:
|
|
|
925
925
|
voting_leaderboards:
|
|
926
926
|
combined_result:
|
|
927
927
|
pre_vote_not_finalized: "Cannot combine results: Pre-Vote sessions not yet finalized: %{names}"
|
|
928
|
+
time_range_result:
|
|
929
|
+
invalid_range: "Invalid time range: the start time must be before the end time."
|
|
928
930
|
|
|
929
931
|
voting:
|
|
930
932
|
errors:
|
data/config/locales/km.yml
CHANGED
|
@@ -681,6 +681,9 @@ km:
|
|
|
681
681
|
voting_contestants:
|
|
682
682
|
assigner:
|
|
683
683
|
not_eliminated: "តែអ្នកចូលប្រកួតដែលត្រូវបានលុបចោលប៉ុណ្ណោះអាចបន្ថែមទៅ Save Contestants session បាន។ %{name} មានស្ថានភាព %{status}។"
|
|
684
|
+
voting_leaderboards:
|
|
685
|
+
time_range_result:
|
|
686
|
+
invalid_range: "ចន្លោះពេលមិនត្រឹមត្រូវ៖ ពេលចាប់ផ្តើមត្រូវតែមុនពេលបញ្ចប់។"
|
|
684
687
|
voting:
|
|
685
688
|
errors:
|
|
686
689
|
no_winners_confirmed: "មិនមានអ្នកឈ្នះត្រូវបានបញ្ជាក់"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Supports the time-filtered leaderboard (VotingLeaderboards::TimeRangeResult), which runs
|
|
2
|
+
# WHERE voting_session_id = ? AND created_at >= ? AND created_at < ?
|
|
3
|
+
# grouped by contestant_id.
|
|
4
|
+
#
|
|
5
|
+
# The plain created_at index was dropped in OptimizeCmVotesIndexes because it was never
|
|
6
|
+
# selected on its own; this composite one leads with voting_session_id so the planner can
|
|
7
|
+
# use it for both the session filter and the range scan.
|
|
8
|
+
class AddSessionCreatedAtIndexToCmVotes < ActiveRecord::Migration[7.0]
|
|
9
|
+
def change
|
|
10
|
+
add_index :cm_votes, %i[voting_session_id created_at], if_not_exists: true
|
|
11
|
+
end
|
|
12
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: spree_cm_commissioner
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.9.
|
|
4
|
+
version: 2.9.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- You
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: spree
|
|
@@ -1889,6 +1889,7 @@ files:
|
|
|
1889
1889
|
- app/overrides/spree/admin/stock_locations/index/index.html.erb.deface
|
|
1890
1890
|
- app/overrides/spree/admin/stores/_form/store_preferences.html.erb.deface
|
|
1891
1891
|
- app/overrides/spree/admin/stores/_form/store_term_and_condition_promotion.html.erb.deface
|
|
1892
|
+
- app/overrides/spree/admin/stores/_form/telegram_auth.html.erb.deface
|
|
1892
1893
|
- app/overrides/spree/admin/stores/form/_layout/default_notification_image.html.erb.deface
|
|
1893
1894
|
- app/overrides/spree/admin/taxonomies/_form/kind.html.erb.deface
|
|
1894
1895
|
- app/overrides/spree/admin/taxons/_form/assets_form.html.erb.deface
|
|
@@ -2408,6 +2409,7 @@ files:
|
|
|
2408
2409
|
- app/services/spree_cm_commissioner/voting_credits/de_allocate.rb
|
|
2409
2410
|
- app/services/spree_cm_commissioner/voting_leaderboards/calculate_score.rb
|
|
2410
2411
|
- app/services/spree_cm_commissioner/voting_leaderboards/combined_result.rb
|
|
2412
|
+
- app/services/spree_cm_commissioner/voting_leaderboards/time_range_result.rb
|
|
2411
2413
|
- app/services/spree_cm_commissioner/voting_sessions/finalize.rb
|
|
2412
2414
|
- app/services/spree_cm_commissioner/waiting_room/publish_lobby_path.rb
|
|
2413
2415
|
- app/services/spree_cm_commissioner/waiting_room/stamp_queue_positions.rb
|
|
@@ -2636,6 +2638,7 @@ files:
|
|
|
2636
2638
|
- app/views/spree/admin/tenants/edit.html.erb
|
|
2637
2639
|
- app/views/spree/admin/tenants/form/_footer.html.erb
|
|
2638
2640
|
- app/views/spree/admin/tenants/form/_social.html.erb
|
|
2641
|
+
- app/views/spree/admin/tenants/form/_telegram_auth.html.erb
|
|
2639
2642
|
- app/views/spree/admin/tenants/index.html.erb
|
|
2640
2643
|
- app/views/spree/admin/tenants/new.html.erb
|
|
2641
2644
|
- app/views/spree/admin/ticket_transfer_rules/_form.html.erb
|
|
@@ -3458,6 +3461,8 @@ files:
|
|
|
3458
3461
|
- db/migrate/20260723000000_add_payment_host_to_cm_tenants.rb
|
|
3459
3462
|
- db/migrate/20260723000001_add_metadata_to_spree_roles.rb
|
|
3460
3463
|
- db/migrate/20260728074153_add_role_to_cm_exports.rb
|
|
3464
|
+
- db/migrate/20260729000000_add_visibility_to_cm_trips.rb
|
|
3465
|
+
- db/migrate/20260729000001_add_session_created_at_index_to_cm_votes.rb
|
|
3461
3466
|
- docker-compose.yml
|
|
3462
3467
|
- docs/api/scoped-access-token-endpoints.md
|
|
3463
3468
|
- docs/option_types/attr_types.md
|