spree_cm_commissioner 2.9.9 → 2.10.0
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 +0 -5
- data/Gemfile.lock +1 -1
- data/app/controllers/spree/admin/users_controller_decorator.rb +36 -0
- data/app/controllers/spree/api/v2/base_controller_decorator.rb +41 -0
- data/app/controllers/spree/api/v2/storefront/telegram_oauth_tokens_controller.rb +8 -22
- data/app/controllers/spree/api/v2/tenant/telegram_oauth_tokens_controller.rb +48 -0
- data/app/interactors/spree_cm_commissioner/base_interactor.rb +10 -0
- data/app/interactors/spree_cm_commissioner/user_fb_token_authenticator.rb +1 -1
- data/app/interactors/spree_cm_commissioner/user_id_token_authenticator.rb +1 -1
- data/app/interactors/spree_cm_commissioner/user_password_authenticator.rb +1 -1
- data/app/interactors/spree_cm_commissioner/user_telegram_oauth_authenticator.rb +4 -4
- data/app/interactors/spree_cm_commissioner/user_telegram_web_app_authenticator.rb +4 -0
- data/app/interactors/spree_cm_commissioner/user_vattanac_bank_web_app_authenticator.rb +4 -0
- data/app/interactors/spree_cm_commissioner/waiting_guests_caller.rb +94 -67
- data/app/interactors/spree_cm_commissioner/waiting_room_latest_system_metadata_puller.rb +20 -6
- data/app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb +83 -20
- data/app/jobs/spree_cm_commissioner/waiting_guests_caller_job.rb +0 -4
- data/app/models/spree_cm_commissioner/guest.rb +5 -3
- data/app/models/spree_cm_commissioner/user_decorator.rb +19 -1
- data/app/models/spree_cm_commissioner/waiting_room_session.rb +9 -2
- data/app/overrides/spree/admin/stores/_form/telegram_auth.html.erb.deface +1 -1
- data/app/overrides/spree/admin/users/edit/account_status.html.erb.deface +42 -0
- data/app/overrides/spree/admin/users/index/body.html.erb.deface +25 -0
- data/app/overrides/spree/admin/users/index/ransack.html.erb.deface +12 -0
- data/app/serializers/spree/v2/tenant/waiting_room_session_serializer.rb +1 -1
- data/app/serializers/spree_cm_commissioner/v2/storefront/waiting_room_session_serializer.rb +1 -1
- data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +6 -12
- data/app/services/spree_cm_commissioner/draft_order/create.rb +14 -0
- data/app/services/spree_cm_commissioner/telegram/check_id_token.rb +33 -0
- data/app/services/spree_cm_commissioner/telegram/exchange_oauth_token.rb +81 -0
- data/app/services/spree_cm_commissioner/telegram/verify_oauth_id_token.rb +98 -0
- data/app/services/spree_cm_commissioner/ticket_transfers/initiate.rb +1 -1
- data/app/services/spree_cm_commissioner/transit_order/create.rb +15 -1
- data/app/services/spree_cm_commissioner/users/block.rb +29 -0
- data/app/services/spree_cm_commissioner/users/unblock.rb +18 -0
- data/app/services/spree_cm_commissioner/waiting_room/publish_lobby_path.rb +7 -11
- data/app/services/spree_cm_commissioner/waiting_room/stamp_queue_positions.rb +94 -95
- data/app/views/spree/admin/tenants/form/_telegram_auth.html.erb +1 -1
- data/config/locales/en.yml +16 -1
- data/config/locales/km.yml +1 -1
- data/config/routes.rb +6 -0
- data/db/migrate/20260723083947_add_max_expired_at_to_cm_waiting_room_sessions.rb +9 -0
- data/db/migrate/20260805000000_add_blocked_at_to_spree_users.rb +6 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +12 -6
- data/app/interactors/spree_cm_commissioner/telegram_oauth_id_token_provider.rb +0 -99
- data/app/interactors/spree_cm_commissioner/telegram_oauth_token_exchanger.rb +0 -71
- data/app/interactors/spree_cm_commissioner/waiting_room_session_firebase_logger.rb +0 -30
- data/app/jobs/spree_cm_commissioner/waiting_room_session_firebase_logger_job.rb +0 -36
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d59a44e1e25ec19ab8c634622f91006ea28bec265ea877dad8565cffa54bad3
|
|
4
|
+
data.tar.gz: 43415e8984f459f0387e110707963f4685c92251f589a2301342f82783c4e714
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1fbfc67d4bc18eb071d854b643ef3bfbaf1312b75f5c241020d58b09855ee234a58064ce08a104ee856b48dec25ce8547ad9c442cbd2648c83ad350c04fec3e1
|
|
7
|
+
data.tar.gz: 53317e748ac053ed6b60ec7e9accdd915266aa6b3e547692865191868d6051693dcad05dee73bed5e196b027fc27436e72fba2c62e05b3b33f806a7558618cf9
|
data/.env.example
CHANGED
|
@@ -22,11 +22,6 @@ WAITING_ROOM_MIN_SESSIONS_COUNT=5
|
|
|
22
22
|
WAITING_ROOM_DISABLED=no
|
|
23
23
|
WAITING_ROOM_POSITION_STAMP_LIMIT=1000
|
|
24
24
|
WAITING_ROOM_FIRESTORE_BATCH_SIZE=500
|
|
25
|
-
WAITING_ROOM_MIN_WAIT_TO_ENTER_SECONDS=600 # Waiting Room step floor (10 min)
|
|
26
|
-
WAITING_ROOM_MIN_QUEUE_TO_ENTER_SECONDS=300 # Queue step floor (5 min)
|
|
27
|
-
WAITING_ROOM_MAX_WAIT_TO_DISPLAY_SECONDS=3600 # above this the ETA is hidden (1 h)
|
|
28
|
-
WAITING_ROOM_CALLER_INTERVAL_SECONDS=60 # how often the caller runs; match the waiting_guests_caller cron
|
|
29
|
-
WAITING_ROOM_MAX_ETA_BATCH_SIZE=50 # cap on batch size assumed when estimating the ETA
|
|
30
25
|
|
|
31
26
|
# Vattanac Bank
|
|
32
27
|
VATTANAC_AES_SECRET_KEY= ""
|
data/Gemfile.lock
CHANGED
|
@@ -26,8 +26,44 @@ module Spree
|
|
|
26
26
|
super
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def block
|
|
30
|
+
return redirect_back_to_user(I18n.t('spree_user.cannot_block_yourself'), :error) if own_account?
|
|
31
|
+
return redirect_back_to_user(I18n.t('spree_user.cannot_block_super_admin'), :error) if @user.super_admin?
|
|
32
|
+
|
|
33
|
+
result = SpreeCmCommissioner::Users::Block.call(user: @user, reason: params[:blocked_reason])
|
|
34
|
+
|
|
35
|
+
if result.success?
|
|
36
|
+
redirect_back_to_user(I18n.t('spree_user.successfully_blocked'), :success)
|
|
37
|
+
else
|
|
38
|
+
redirect_back_to_user(result.error.to_s, :error)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def unblock
|
|
43
|
+
result = SpreeCmCommissioner::Users::Unblock.call(user: @user)
|
|
44
|
+
|
|
45
|
+
if result.success?
|
|
46
|
+
redirect_back_to_user(I18n.t('spree_user.successfully_unblocked'), :success)
|
|
47
|
+
else
|
|
48
|
+
redirect_back_to_user(result.error.to_s, :error)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
29
52
|
private
|
|
30
53
|
|
|
54
|
+
# An admin who blocks themselves would be locked out with no way back in through the UI.
|
|
55
|
+
def own_account?
|
|
56
|
+
@user.id == try_spree_current_user&.id
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Blocking is triggered from two places — the row action on the users list and the account
|
|
60
|
+
# status card on the edit page — so return to whichever one the admin came from instead of
|
|
61
|
+
# always landing them on the edit page.
|
|
62
|
+
def redirect_back_to_user(message, type)
|
|
63
|
+
flash[type] = message
|
|
64
|
+
redirect_back fallback_location: spree.edit_admin_user_path(@user)
|
|
65
|
+
end
|
|
66
|
+
|
|
31
67
|
# Role types the user form doesn't render (assigned in organizer dashboards), e.g. check-in groups and preview.
|
|
32
68
|
NON_FORM_ROLE_TYPES = %i[check_in preview].freeze
|
|
33
69
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V2
|
|
4
|
+
# Single enforcement point for blocked users on the API. Every authenticated v2 endpoint -
|
|
5
|
+
# storefront, tenant, organizer, platform - resolves its user through
|
|
6
|
+
# Spree::Api::V2::BaseController#spree_current_user, so guarding here covers them all,
|
|
7
|
+
# including the controllers that inherit BaseController directly rather than
|
|
8
|
+
# ResourceController.
|
|
9
|
+
#
|
|
10
|
+
# 403 rather than 401: the token is valid and the caller is authenticated, they are simply not
|
|
11
|
+
# allowed anymore. A 401 would tell clients to refresh the token, which would loop.
|
|
12
|
+
module BaseControllerDecorator
|
|
13
|
+
# Machine-readable, not translated, so clients can branch on it the same way they already
|
|
14
|
+
# branch on the authenticators' `account_temporarily_deleted`.
|
|
15
|
+
BLOCKED_ERROR = 'account_blocked'.freeze
|
|
16
|
+
|
|
17
|
+
def self.prepended(base)
|
|
18
|
+
base.before_action :reject_blocked_user
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def reject_blocked_user
|
|
24
|
+
return unless blocked_token_owner?
|
|
25
|
+
|
|
26
|
+
render_error_payload(BLOCKED_ERROR, 403)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Deliberately reads the owner off the token instead of calling spree_current_user: that
|
|
30
|
+
# helper runs doorkeeper_authorize!, which would start rejecting invalid tokens on public
|
|
31
|
+
# endpoints that today ignore them. No token (anonymous request) costs nothing.
|
|
32
|
+
def blocked_token_owner?
|
|
33
|
+
owner = doorkeeper_token&.resource_owner
|
|
34
|
+
owner.respond_to?(:blocked?) && owner.blocked?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Spree::Api::V2::BaseController.prepend(Spree::Api::V2::BaseControllerDecorator)
|
|
@@ -11,13 +11,13 @@ module Spree
|
|
|
11
11
|
def create
|
|
12
12
|
return render_error_payload(credential_mismatch_message) if credential_mismatch_message
|
|
13
13
|
|
|
14
|
-
result = SpreeCmCommissioner::
|
|
14
|
+
result = SpreeCmCommissioner::Telegram::ExchangeOauthToken.call(
|
|
15
15
|
exchange_params.merge(client_secret: resolved_client_secret)
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
-
return render_error_payload(result.
|
|
18
|
+
return render_error_payload(result.error) unless result.success?
|
|
19
19
|
|
|
20
|
-
render json: result.body, status: result.status, content_type: 'application/json'
|
|
20
|
+
render json: result.value[:body], status: result.value[:status], content_type: 'application/json'
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
private
|
|
@@ -26,33 +26,19 @@ module Spree
|
|
|
26
26
|
params.permit(:client_id, :code, :redirect_uri, :code_verifier).to_h.symbolize_keys
|
|
27
27
|
end
|
|
28
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
29
|
def credential_mismatch_message
|
|
35
|
-
return
|
|
36
|
-
return
|
|
37
|
-
return 'telegram_oauth_client_id_mismatch' unless client_id_matches_owner?
|
|
30
|
+
return nil if current_store.preferred_telegram_oauth_client_id.blank?
|
|
31
|
+
return 'telegram_oauth_client_id_mismatch' unless client_id_matches_store?
|
|
38
32
|
|
|
39
33
|
nil
|
|
40
34
|
end
|
|
41
35
|
|
|
42
|
-
def
|
|
43
|
-
|
|
36
|
+
def client_id_matches_store?
|
|
37
|
+
current_store.preferred_telegram_oauth_client_id == exchange_params[:client_id]
|
|
44
38
|
end
|
|
45
39
|
|
|
46
40
|
def resolved_client_secret
|
|
47
|
-
|
|
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
|
|
41
|
+
current_store.preferred_telegram_oauth_client_secret.presence
|
|
56
42
|
end
|
|
57
43
|
end
|
|
58
44
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V2
|
|
4
|
+
module Tenant
|
|
5
|
+
# Identical PKCE code->id_token relay as Storefront::TelegramOauthTokensController,
|
|
6
|
+
# but scoped to the tenant behind the Doorkeeper app access token (see
|
|
7
|
+
# Tenant::BaseController#require_tenant), exposed under /api/v2/tenant so
|
|
8
|
+
# tenant-scoped clients can call it alongside /api/v2/tenant/token
|
|
9
|
+
class TelegramOauthTokensController < BaseController
|
|
10
|
+
def create
|
|
11
|
+
return render_error_payload(credential_mismatch_message) if credential_mismatch_message
|
|
12
|
+
|
|
13
|
+
result = SpreeCmCommissioner::Telegram::ExchangeOauthToken.call(
|
|
14
|
+
exchange_params.merge(client_secret: resolved_client_secret)
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
return render_error_payload(result.error) unless result.success?
|
|
18
|
+
|
|
19
|
+
render json: result.value[:body], status: result.value[:status], content_type: 'application/json'
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def exchange_params
|
|
25
|
+
params.permit(:client_id, :code, :redirect_uri, :code_verifier).to_h.symbolize_keys
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @tenant is already resolved from the Doorkeeper app (see BaseController#require_tenant),
|
|
29
|
+
# so unlike the storefront endpoint there's no separate tenant_id param to validate.
|
|
30
|
+
def credential_mismatch_message
|
|
31
|
+
return nil if @tenant.preferred_telegram_oauth_client_id.blank?
|
|
32
|
+
return 'telegram_oauth_client_id_mismatch' unless client_id_matches_tenant?
|
|
33
|
+
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def client_id_matches_tenant?
|
|
38
|
+
@tenant.preferred_telegram_oauth_client_id == exchange_params[:client_id]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def resolved_client_secret
|
|
42
|
+
@tenant.preferred_telegram_oauth_client_secret.presence
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
module SpreeCmCommissioner
|
|
2
2
|
class BaseInteractor
|
|
3
3
|
include Interactor
|
|
4
|
+
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# Shared tail guard for the authenticators: an account can be resolved and hold a valid
|
|
8
|
+
# credential and still not be allowed to authenticate. Both messages are error_description values
|
|
9
|
+
# the mobile/web clients branch on, so they stay raw strings rather than translations.
|
|
10
|
+
def reject_unauthenticatable_user!(user)
|
|
11
|
+
context.fail!(message: 'account_temporarily_deleted') if user.soft_deleted?
|
|
12
|
+
context.fail!(message: 'account_blocked') if user.blocked?
|
|
13
|
+
end
|
|
4
14
|
end
|
|
5
15
|
end
|
|
@@ -16,7 +16,7 @@ module SpreeCmCommissioner
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
context.fail!(message: I18n.t('authenticator.incorrect_password')) unless validate_password(context.user)
|
|
19
|
-
|
|
19
|
+
reject_unauthenticatable_user!(context.user)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
private
|
|
@@ -13,16 +13,16 @@ module SpreeCmCommissioner
|
|
|
13
13
|
provider = verified_provider
|
|
14
14
|
context.user = find_or_register_user(provider)
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
reject_unauthenticatable_user!(context.user)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
21
|
def verified_provider
|
|
22
|
-
result = SpreeCmCommissioner::
|
|
23
|
-
context.fail!(message: result.
|
|
22
|
+
result = SpreeCmCommissioner::Telegram::VerifyOauthIdToken.call(id_token: id_token, client_id: telegram_client_id)
|
|
23
|
+
context.fail!(message: result.error.to_s) unless result.success?
|
|
24
24
|
|
|
25
|
-
result.provider
|
|
25
|
+
result.value[:provider]
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# Each tenant runs its own Telegram bot, so the audience an id_token must
|
|
@@ -8,6 +8,10 @@ module SpreeCmCommissioner
|
|
|
8
8
|
|
|
9
9
|
context.telegram_user = ActiveSupport::JSON.decode(checker_context.decoded_telegram_init_data['user'])
|
|
10
10
|
context.user = find_or_create_telegram_user
|
|
11
|
+
|
|
12
|
+
# Block check only — this flow has never rejected soft-deleted accounts and this change is not
|
|
13
|
+
# the place to start.
|
|
14
|
+
context.fail!(message: 'account_blocked') if context.user.blocked?
|
|
11
15
|
end
|
|
12
16
|
|
|
13
17
|
def find_or_create_telegram_user
|
|
@@ -8,6 +8,10 @@ module SpreeCmCommissioner
|
|
|
8
8
|
|
|
9
9
|
verify_token!
|
|
10
10
|
|
|
11
|
+
# Block check only — this flow has never rejected soft-deleted accounts and this change is not
|
|
12
|
+
# the place to start.
|
|
13
|
+
return context.fail!(message: 'account_blocked') if user.blocked?
|
|
14
|
+
|
|
11
15
|
context.user = user
|
|
12
16
|
rescue JWT::DecodeError => e
|
|
13
17
|
context.fail!(message: "Decode error: #{e.message}")
|
|
@@ -3,12 +3,6 @@ require 'google/cloud/firestore'
|
|
|
3
3
|
# TODO: alert when available_slots is negative.
|
|
4
4
|
module SpreeCmCommissioner
|
|
5
5
|
class WaitingGuestsCaller < BaseInteractor
|
|
6
|
-
# 10 min floor — Waiting Room step (full journey, most uncertainty)
|
|
7
|
-
MIN_WAIT_TO_ENTER_SECONDS = (ENV['WAITING_ROOM_MIN_WAIT_TO_ENTER_SECONDS'] || 600).to_i
|
|
8
|
-
|
|
9
|
-
# 5 min floor — Queue step (position assigned, sub-segment of the journey)
|
|
10
|
-
MIN_QUEUE_TO_ENTER_SECONDS = (ENV['WAITING_ROOM_MIN_QUEUE_TO_ENTER_SECONDS'] || 300).to_i
|
|
11
|
-
|
|
12
6
|
# Firestore bounds a batch update by payload size (10 MiB); 500 ops/commit leaves us far under that.
|
|
13
7
|
FIRESTORE_BATCH_SIZE = (ENV['WAITING_ROOM_FIRESTORE_BATCH_SIZE'] || 500).to_i
|
|
14
8
|
|
|
@@ -16,8 +10,7 @@ module SpreeCmCommissioner
|
|
|
16
10
|
# (cold start / lull → active_sessions low → available_slots ≈ max_sessions, e.g. 900) would
|
|
17
11
|
# otherwise release every queued guest at once, dumping 900 simultaneous session-token requests
|
|
18
12
|
# on the server. Capping paces entry at ~MAX_CALLS_PER_RUN/min (the caller runs every minute);
|
|
19
|
-
# the rest stay queued and drain over the following runs.
|
|
20
|
-
# so the per-batch ETA the app shows reflects the real release rate.
|
|
13
|
+
# the rest stay queued and drain over the following runs.
|
|
21
14
|
MAX_CALLS_PER_RUN = (ENV['WAITING_ROOM_MAX_CALLS_PER_RUN'] || 50).to_i
|
|
22
15
|
|
|
23
16
|
def call
|
|
@@ -25,9 +18,8 @@ module SpreeCmCommissioner
|
|
|
25
18
|
available_slots = fetch_available_slots
|
|
26
19
|
call_limit = [available_slots, MAX_CALLS_PER_RUN].min
|
|
27
20
|
|
|
28
|
-
# Always run through to mark_as — even with no slots — so the lobby keeps a
|
|
29
|
-
#
|
|
30
|
-
# and looking like a dead cron.
|
|
21
|
+
# Always run through to mark_as/publish_logs — even with no slots — so the lobby keeps a
|
|
22
|
+
# fresh heartbeat during peak/full periods instead of going stale and looking like a dead cron.
|
|
31
23
|
long_waiting_guests = call_limit.positive? ? fetch_long_waiting_guests(call_limit) : []
|
|
32
24
|
calling_all(long_waiting_guests) if long_waiting_guests.any?
|
|
33
25
|
|
|
@@ -35,13 +27,11 @@ module SpreeCmCommissioner
|
|
|
35
27
|
full: long_waiting_guests.size >= available_slots,
|
|
36
28
|
available_slots: available_slots - long_waiting_guests.size,
|
|
37
29
|
has_queue: waiting_exists?([previous_records_path, records_path]),
|
|
38
|
-
avg_wait_to_enter_seconds: compute_avg_wait_to_enter_seconds(long_waiting_guests),
|
|
39
|
-
avg_queue_to_enter_seconds: compute_avg_queue_to_enter_seconds(long_waiting_guests),
|
|
40
|
-
recent_slots_per_call: call_limit.positive? ? call_limit : nil,
|
|
41
30
|
active_sessions: @active_sessions,
|
|
42
|
-
max_sessions: @max_sessions
|
|
43
|
-
logs: call_logs(started_at: started_at, called_count: long_waiting_guests.size)
|
|
31
|
+
max_sessions: @max_sessions
|
|
44
32
|
)
|
|
33
|
+
|
|
34
|
+
publish_logs(started_at: started_at, called_count: long_waiting_guests.size)
|
|
45
35
|
end
|
|
46
36
|
|
|
47
37
|
def fetch_available_slots
|
|
@@ -105,22 +95,84 @@ module SpreeCmCommissioner
|
|
|
105
95
|
"waiting_guests/#{date}/records"
|
|
106
96
|
end
|
|
107
97
|
|
|
108
|
-
# For alert waiting guests to enter room, we
|
|
109
|
-
#
|
|
98
|
+
# For alert waiting guests to enter room, we update :allow_to_enter_room_at and (when precreation
|
|
99
|
+
# succeeded) a :session map with a usable token — the app reads it straight off this same
|
|
100
|
+
# snapshot and enters without a separate createSession round trip. Precreating first, then
|
|
101
|
+
# writing both fields in the same batch, is what makes grant + session arrive atomically: a
|
|
102
|
+
# guest can never observe "allowed in" without also having a session, and there's no window for
|
|
103
|
+
# the old race where the app's own createSession beat this job's precreate to the same row.
|
|
110
104
|
#
|
|
111
105
|
# Commit in Firestore batches (chunks of FIRESTORE_BATCH_SIZE) instead of one update per guest,
|
|
112
106
|
# so e.g. 1000 guests = 2 commits, not 1000 round-trips. update merges, so we only send the
|
|
113
|
-
# changed
|
|
107
|
+
# changed fields and leave the rest of each doc intact.
|
|
114
108
|
def calling_all(waiting_guests)
|
|
115
109
|
allow_at = Time.zone.now
|
|
110
|
+
sessions_by_guest_id = precreate_sessions(waiting_guests)
|
|
116
111
|
|
|
117
112
|
waiting_guests.each_slice(FIRESTORE_BATCH_SIZE) do |slice|
|
|
118
113
|
firestore.batch do |b|
|
|
119
|
-
slice.each
|
|
114
|
+
slice.each do |document|
|
|
115
|
+
session = sessions_by_guest_id[document.ref.document_id]
|
|
116
|
+
data = {
|
|
117
|
+
allow_to_enter_room_at: allow_at,
|
|
118
|
+
session: {
|
|
119
|
+
id: session.id,
|
|
120
|
+
jwt_token: session.jwt_token,
|
|
121
|
+
expired_at: session.expired_at,
|
|
122
|
+
created_at: session.created_at
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
b.update(document.ref, data)
|
|
126
|
+
end
|
|
120
127
|
end
|
|
121
128
|
end
|
|
122
129
|
end
|
|
123
130
|
|
|
131
|
+
# Pre-creates a real, usable WaitingRoomSession row (real signed JWT, not a placeholder) for
|
|
132
|
+
# every called guest, and returns them keyed by guest_identifier so #calling_all can hand each
|
|
133
|
+
# guest their own session in the same Firestore write that grants entry.
|
|
134
|
+
#
|
|
135
|
+
# A no-show's row simply expires on the normal TTL (no separate reclaim job needed). These rows
|
|
136
|
+
# only contain identity and timing; device attributes and remote_ip are left for the guest's real
|
|
137
|
+
# createSession call to fill in on renewal, keeping compatibility with older clients that never
|
|
138
|
+
# read the Firestore-carried session at all — they just call createSession like before and hit
|
|
139
|
+
# WaitingRoomSessionCreator's existing renew-active-row path.
|
|
140
|
+
#
|
|
141
|
+
# MAX_CALLS_PER_RUN caps this at ≤50 rows/run, making per-row writes cheap enough to avoid raw
|
|
142
|
+
# SQL batching. The guest_identifier unique index + rescue handles the concurrent-race case (like
|
|
143
|
+
# the guest's app calling createSession first) by looking up whichever row won — unscoped, not
|
|
144
|
+
# `.active`, so a row that's already inactive by the time we look is still picked up rather than
|
|
145
|
+
# treated as absent. `find_by!` (not `find_by`) is deliberate: allow_to_enter_room_at drops a
|
|
146
|
+
# guest out of every future run's eligibility filter for good (see #eligible_guests_in), and the
|
|
147
|
+
# app has no fallback of its own (entry is session-presence-only), so a guest that truly ends up
|
|
148
|
+
# without a row here — the colliding row vanishing between the unique-index hit and this lookup —
|
|
149
|
+
# is a bug worth a loud `ActiveRecord::RecordNotFound`, not a silently skipped `session` field.
|
|
150
|
+
def precreate_sessions(waiting_guests)
|
|
151
|
+
return {} if waiting_guests.empty?
|
|
152
|
+
|
|
153
|
+
waiting_guests.each_with_object({}) do |doc, sessions|
|
|
154
|
+
guest_id = doc.ref.document_id
|
|
155
|
+
expired_at = precreated_expired_at
|
|
156
|
+
|
|
157
|
+
sessions[guest_id] = SpreeCmCommissioner::WaitingRoomSession.create!(
|
|
158
|
+
guest_identifier: guest_id,
|
|
159
|
+
jwt_token: JWT.encode({ exp: expired_at.to_i }, ENV.fetch('WAITING_ROOM_SESSION_SIGNATURE'), 'HS256'),
|
|
160
|
+
expired_at: expired_at,
|
|
161
|
+
max_expired_at: precreated_max_expired_at
|
|
162
|
+
)
|
|
163
|
+
rescue ActiveRecord::RecordNotUnique
|
|
164
|
+
sessions[guest_id] = SpreeCmCommissioner::WaitingRoomSession.find_by!(guest_identifier: guest_id)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def precreated_expired_at
|
|
169
|
+
SpreeCmCommissioner::WaitingRoomSessionCreator::WAITING_ROOM_SESSION_EXPIRE_DURATION_IN_SECOND.seconds.from_now
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def precreated_max_expired_at
|
|
173
|
+
SpreeCmCommissioner::WaitingRoomSessionCreator::WAITING_ROOM_SESSION_MAX_DURATION_IN_SECOND.seconds.from_now
|
|
174
|
+
end
|
|
175
|
+
|
|
124
176
|
def current_date
|
|
125
177
|
Time.zone.now.strftime('%Y-%m-%d')
|
|
126
178
|
end
|
|
@@ -129,69 +181,40 @@ module SpreeCmCommissioner
|
|
|
129
181
|
1.day.ago.strftime('%Y-%m-%d')
|
|
130
182
|
end
|
|
131
183
|
|
|
132
|
-
|
|
133
|
-
# App-facing fields plus live capacity state (full / available_slots / active_sessions /
|
|
134
|
-
# max_sessions / ETAs) stay top-level; this run's observability snapshot is namespaced under
|
|
135
|
-
# `logs.waiting_guests_caller_job` (see #call_logs). merge: true deep-merges nested maps, so
|
|
136
|
-
# each job owns its own key under `logs` without clobbering the others.
|
|
137
|
-
def mark_as( # rubocop:disable Metrics/ParameterLists
|
|
184
|
+
def mark_as(
|
|
138
185
|
full:,
|
|
139
186
|
available_slots:,
|
|
140
187
|
has_queue:,
|
|
141
|
-
avg_wait_to_enter_seconds: nil,
|
|
142
|
-
avg_queue_to_enter_seconds: nil,
|
|
143
|
-
recent_slots_per_call: nil,
|
|
144
188
|
active_sessions: nil,
|
|
145
|
-
max_sessions: nil
|
|
146
|
-
logs: nil
|
|
189
|
+
max_sessions: nil
|
|
147
190
|
)
|
|
148
|
-
data = {
|
|
191
|
+
data = {
|
|
192
|
+
full: full,
|
|
193
|
+
available_slots: available_slots,
|
|
194
|
+
has_queue: has_queue,
|
|
195
|
+
updated_at: Time.zone.now
|
|
196
|
+
}
|
|
149
197
|
|
|
150
|
-
data[:avg_wait_to_enter_seconds] = avg_wait_to_enter_seconds if avg_wait_to_enter_seconds
|
|
151
|
-
data[:avg_queue_to_enter_seconds] = avg_queue_to_enter_seconds if avg_queue_to_enter_seconds
|
|
152
|
-
data[:recent_slots_per_call] = recent_slots_per_call if recent_slots_per_call
|
|
153
198
|
data[:active_sessions] = active_sessions unless active_sessions.nil?
|
|
154
199
|
data[:max_sessions] = max_sessions unless max_sessions.nil?
|
|
155
|
-
data[:logs] = { waiting_guests_caller_job: logs } if logs
|
|
156
200
|
|
|
157
201
|
lobby_document.set(data, merge: true)
|
|
158
202
|
end
|
|
159
203
|
|
|
160
204
|
# Point-in-time snapshot (overwritten each run, never cumulative) of this caller run: last-run
|
|
161
205
|
# timing (heartbeat) and how many guests were called. Capacity state (active/max sessions,
|
|
162
|
-
# available_slots) lives
|
|
163
|
-
def
|
|
206
|
+
# available_slots) lives on the lobby doc itself, not here — see #mark_as.
|
|
207
|
+
def publish_logs(started_at:, called_count:)
|
|
164
208
|
finished_at = Time.zone.now
|
|
165
|
-
{
|
|
166
|
-
called_count: called_count,
|
|
167
|
-
last_started_at: started_at,
|
|
168
|
-
last_finished_at: finished_at,
|
|
169
|
-
last_duration_ms: ((finished_at - started_at) * 1000).round
|
|
170
|
-
}
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
# Average total wait (queued_at → allow_to_enter_room_at) for just-called guests.
|
|
174
|
-
# Used for the Waiting Room step ETA: how long until I get in from when I joined.
|
|
175
|
-
def compute_avg_wait_to_enter_seconds(guests)
|
|
176
|
-
return nil if guests.empty?
|
|
177
|
-
|
|
178
|
-
now = Time.zone.now
|
|
179
|
-
total = guests.sum { |doc| (now - doc.data[:queued_at].to_time).to_f }
|
|
180
|
-
[(total / guests.size).round, MIN_WAIT_TO_ENTER_SECONDS].max
|
|
181
|
-
end
|
|
182
209
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return nil if positioned.empty?
|
|
192
|
-
|
|
193
|
-
total = positioned.sum { |doc| (now - doc.data[:position_set_at].to_time).to_f }
|
|
194
|
-
[(total / positioned.size).round, MIN_QUEUE_TO_ENTER_SECONDS].max
|
|
210
|
+
logs_document.set(
|
|
211
|
+
{
|
|
212
|
+
called_count: called_count,
|
|
213
|
+
last_started_at: started_at,
|
|
214
|
+
last_finished_at: finished_at,
|
|
215
|
+
last_duration_ms: ((finished_at - started_at) * 1000).round
|
|
216
|
+
}
|
|
217
|
+
)
|
|
195
218
|
end
|
|
196
219
|
|
|
197
220
|
def lobby_data
|
|
@@ -202,6 +225,10 @@ module SpreeCmCommissioner
|
|
|
202
225
|
@lobby_document ||= firestore.col('waiting_rooms').doc('lobby')
|
|
203
226
|
end
|
|
204
227
|
|
|
228
|
+
def logs_document
|
|
229
|
+
@logs_document ||= lobby_document.col('logs').doc('waiting_guests_caller_job')
|
|
230
|
+
end
|
|
231
|
+
|
|
205
232
|
def fetch_max_sessions
|
|
206
233
|
fetcher = SpreeCmCommissioner::WaitingRoomSystemMetadataFetcher.new(firestore: firestore)
|
|
207
234
|
fetcher.load_document_data
|
|
@@ -1,18 +1,32 @@
|
|
|
1
1
|
require 'aws-sdk-ecs'
|
|
2
2
|
|
|
3
|
-
# TODO: UI to check all instances, max sessions, active_sessions, and available slots
|
|
4
3
|
module SpreeCmCommissioner
|
|
5
4
|
class WaitingRoomLatestSystemMetadataPuller < BaseInteractor
|
|
6
5
|
def call
|
|
6
|
+
region = ENV.fetch('AWS_REGION', nil)
|
|
7
|
+
access_key_id = ENV.fetch('AWS_ACCESS_KEY_ID', nil)
|
|
8
|
+
secret_access_key = ENV.fetch('AWS_SECRET_ACCESS_KEY', nil)
|
|
9
|
+
cluster_name = ENV.fetch('AWS_CLUSTER_NAME', nil)
|
|
10
|
+
cluster_service_name = ENV.fetch('AWS_CLUSTER_SERVICE_NAME', nil)
|
|
11
|
+
|
|
12
|
+
# On dev/staging, these env vars might not be set, so skip the job instead of raising.
|
|
13
|
+
return if [
|
|
14
|
+
region,
|
|
15
|
+
access_key_id,
|
|
16
|
+
secret_access_key,
|
|
17
|
+
cluster_name,
|
|
18
|
+
cluster_service_name
|
|
19
|
+
].any?(&:nil?)
|
|
20
|
+
|
|
7
21
|
ecs_client = Aws::ECS::Client.new(
|
|
8
|
-
region:
|
|
9
|
-
access_key_id:
|
|
10
|
-
secret_access_key:
|
|
22
|
+
region: region,
|
|
23
|
+
access_key_id: access_key_id,
|
|
24
|
+
secret_access_key: secret_access_key
|
|
11
25
|
)
|
|
12
26
|
|
|
13
27
|
response = ecs_client.describe_services(
|
|
14
|
-
cluster:
|
|
15
|
-
services: [
|
|
28
|
+
cluster: cluster_name,
|
|
29
|
+
services: [cluster_service_name]
|
|
16
30
|
)
|
|
17
31
|
|
|
18
32
|
running_count = response.services.first.running_count
|