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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +0 -5
  3. data/Gemfile.lock +1 -1
  4. data/app/controllers/spree/admin/users_controller_decorator.rb +36 -0
  5. data/app/controllers/spree/api/v2/base_controller_decorator.rb +41 -0
  6. data/app/controllers/spree/api/v2/storefront/telegram_oauth_tokens_controller.rb +8 -22
  7. data/app/controllers/spree/api/v2/tenant/telegram_oauth_tokens_controller.rb +48 -0
  8. data/app/interactors/spree_cm_commissioner/base_interactor.rb +10 -0
  9. data/app/interactors/spree_cm_commissioner/user_fb_token_authenticator.rb +1 -1
  10. data/app/interactors/spree_cm_commissioner/user_id_token_authenticator.rb +1 -1
  11. data/app/interactors/spree_cm_commissioner/user_password_authenticator.rb +1 -1
  12. data/app/interactors/spree_cm_commissioner/user_telegram_oauth_authenticator.rb +4 -4
  13. data/app/interactors/spree_cm_commissioner/user_telegram_web_app_authenticator.rb +4 -0
  14. data/app/interactors/spree_cm_commissioner/user_vattanac_bank_web_app_authenticator.rb +4 -0
  15. data/app/interactors/spree_cm_commissioner/waiting_guests_caller.rb +94 -67
  16. data/app/interactors/spree_cm_commissioner/waiting_room_latest_system_metadata_puller.rb +20 -6
  17. data/app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb +83 -20
  18. data/app/jobs/spree_cm_commissioner/waiting_guests_caller_job.rb +0 -4
  19. data/app/models/spree_cm_commissioner/guest.rb +5 -3
  20. data/app/models/spree_cm_commissioner/user_decorator.rb +19 -1
  21. data/app/models/spree_cm_commissioner/waiting_room_session.rb +9 -2
  22. data/app/overrides/spree/admin/stores/_form/telegram_auth.html.erb.deface +1 -1
  23. data/app/overrides/spree/admin/users/edit/account_status.html.erb.deface +42 -0
  24. data/app/overrides/spree/admin/users/index/body.html.erb.deface +25 -0
  25. data/app/overrides/spree/admin/users/index/ransack.html.erb.deface +12 -0
  26. data/app/serializers/spree/v2/tenant/waiting_room_session_serializer.rb +1 -1
  27. data/app/serializers/spree_cm_commissioner/v2/storefront/waiting_room_session_serializer.rb +1 -1
  28. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +6 -12
  29. data/app/services/spree_cm_commissioner/draft_order/create.rb +14 -0
  30. data/app/services/spree_cm_commissioner/telegram/check_id_token.rb +33 -0
  31. data/app/services/spree_cm_commissioner/telegram/exchange_oauth_token.rb +81 -0
  32. data/app/services/spree_cm_commissioner/telegram/verify_oauth_id_token.rb +98 -0
  33. data/app/services/spree_cm_commissioner/ticket_transfers/initiate.rb +1 -1
  34. data/app/services/spree_cm_commissioner/transit_order/create.rb +15 -1
  35. data/app/services/spree_cm_commissioner/users/block.rb +29 -0
  36. data/app/services/spree_cm_commissioner/users/unblock.rb +18 -0
  37. data/app/services/spree_cm_commissioner/waiting_room/publish_lobby_path.rb +7 -11
  38. data/app/services/spree_cm_commissioner/waiting_room/stamp_queue_positions.rb +94 -95
  39. data/app/views/spree/admin/tenants/form/_telegram_auth.html.erb +1 -1
  40. data/config/locales/en.yml +16 -1
  41. data/config/locales/km.yml +1 -1
  42. data/config/routes.rb +6 -0
  43. data/db/migrate/20260723083947_add_max_expired_at_to_cm_waiting_room_sessions.rb +9 -0
  44. data/db/migrate/20260805000000_add_blocked_at_to_spree_users.rb +6 -0
  45. data/lib/spree_cm_commissioner/version.rb +1 -1
  46. metadata +12 -6
  47. data/app/interactors/spree_cm_commissioner/telegram_oauth_id_token_provider.rb +0 -99
  48. data/app/interactors/spree_cm_commissioner/telegram_oauth_token_exchanger.rb +0 -71
  49. data/app/interactors/spree_cm_commissioner/waiting_room_session_firebase_logger.rb +0 -30
  50. 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: 391b15d7aada08289da9a21d18a9d48d461ecf1fe0c36a361a276df201455a5e
4
- data.tar.gz: af3f07ac5d9e449cb5300949dbbcdcb80981b78b41f0aa23a396ce6cdd49e68b
3
+ metadata.gz: 6d59a44e1e25ec19ab8c634622f91006ea28bec265ea877dad8565cffa54bad3
4
+ data.tar.gz: 43415e8984f459f0387e110707963f4685c92251f589a2301342f82783c4e714
5
5
  SHA512:
6
- metadata.gz: 49e38586ae76dfcf37be8bb6f9c4ddef60a2ef694f491ffb648b5903070dd0932c2efb1d936f3c5a6b4c1af16355b2ddc26e4706cc04c7ed32bed0908ed88db5
7
- data.tar.gz: be58778bd8a6124b8b3c914e9c0c83a44dce42a75c1df0f677ef00868bc388f3fa88518828e1833b701366eedf551e03a30528919a2c1f8ea16aec108d92b8fe
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
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.9.9)
37
+ spree_cm_commissioner (2.10.0)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -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::TelegramOauthTokenExchanger.call(
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.message) unless result.success?
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 '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?
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 client_id_matches_owner?
43
- credential_owner.preferred_telegram_oauth_client_id == exchange_params[:client_id]
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
- 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
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
@@ -12,7 +12,7 @@ module SpreeCmCommissioner
12
12
  checker.user
13
13
  end
14
14
 
15
- context.fail!(message: 'account_temporarily_deleted') if context.user.soft_deleted?
15
+ reject_unauthenticatable_user!(context.user)
16
16
  end
17
17
 
18
18
  private
@@ -7,7 +7,7 @@ module SpreeCmCommissioner
7
7
 
8
8
  update_user_email if context.user.email.blank?
9
9
 
10
- context.fail!(message: 'account_temporarily_deleted') if context.user.soft_deleted?
10
+ reject_unauthenticatable_user!(context.user)
11
11
  end
12
12
 
13
13
  def register_user
@@ -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
- context.fail!(message: 'account_temporarily_deleted') if context.user.soft_deleted?
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
- context.fail!(message: 'account_temporarily_deleted') if context.user.soft_deleted?
16
+ reject_unauthenticatable_user!(context.user)
17
17
  end
18
18
 
19
19
  private
20
20
 
21
21
  def verified_provider
22
- result = SpreeCmCommissioner::TelegramOauthIdTokenProvider.call(id_token: id_token, client_id: telegram_client_id)
23
- context.fail!(message: result.message) unless result.success?
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. Matches the stamper's MAX_ETA_BATCH_SIZE
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 fresh heartbeat
29
- # (logs.waiting_guests_caller_job) during peak/full periods instead of going stale
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 just update :allow_to_enter_room_at.
109
- # App will listen to firebase & start refresh session token to enter room.
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 field and leave the rest of each doc intact.
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 { |document| b.update(document.ref, { allow_to_enter_room_at: allow_at }) }
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
- # merge: true so we preserve the published `waiting_guests_records_path` on the lobby doc.
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 = { full: full, available_slots: available_slots, has_queue: has_queue }
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 top-level, not here.
163
- def call_logs(started_at:, called_count:)
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
- # Average queue wait (position_set_at → allow_to_enter_room_at) for just-called guests.
184
- # Used for the Queue step ETA: how long after getting a position until I actually enter.
185
- # Returns nil when none of the called guests had position_set_at (first run, or no prior stamp).
186
- def compute_avg_queue_to_enter_seconds(guests)
187
- return nil if guests.empty?
188
-
189
- now = Time.zone.now
190
- positioned = guests.select { |doc| doc.data[:position_set_at] }
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: ENV.fetch('AWS_REGION'),
9
- access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID'),
10
- secret_access_key: ENV.fetch('AWS_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: ENV.fetch('AWS_CLUSTER_NAME'),
15
- services: [ENV.fetch('AWS_CLUSTER_SERVICE_NAME')]
28
+ cluster: cluster_name,
29
+ services: [cluster_service_name]
16
30
  )
17
31
 
18
32
  running_count = response.services.first.running_count