Auditar PRO por validade de filiação e status de documentação
TLDR: Varredura pontual em duas fases no console — a fase 1 audita todos os PRO ativos contra o CITRG e imprime os inelegíveis, a fase 2 rebaixa essa lista com
DRY_RUNligado por padrão.
Pré-requisitos
- Acesso ao console da aplicação:
bash make run.console - Regra de referência: pro_eligibility_validity_and_documentation (R-004)
- As duas fases rodam na mesma sessão de console: a fase 2 consome a lista
INELIGIBLEdefinida pela fase 1. Rode a fase 1, leia a saída, e só então rode a fase 2.
Passos
Fase 1 — auditoria (somente leitura)
```ruby THREADS = 10 APPROVED_DOCUMENTATION_STATUS = “ok”
| whitelisted = TherapistWhitelist.pluck(:email).map { | email.to_s.downcase }.to_set |
targets = User.joins(subscription: :subscription_plan) .where(subscription_plans: {slug: SubscriptionPlan::PRO_PLAN_SLUG}) .where(subscriptions: {status: Subscription.statuses[:active]}) .reorder(:id) .pluck(:id, :email, :name) .map { |id, email, name| {id: id, email: email, name: name} } .reject { |user| whitelisted.include?(user[:email].to_s.downcase) }
puts “active PRO to check: #{targets.size} (skipping #{whitelisted.size} whitelisted)”
pending = Queue.new targets.each { |target| pending « target } collected = Queue.new counter = 0 lock = Mutex.new
workers = THREADS.times.map do Thread.new do loop do user = begin pending.pop(true) rescue ThreadError break end
row = begin
api = CITRG::MembershipByEmail.new(user[:email]).perform
if api.not_found?
user.merge(reasons: [:no_membership])
elsif !api.success?
user.merge(reasons: [:citrg_unavailable], detail: "HTTP #{api.send(:response).code}")
else
valid_until = api.membership[:valid_until_timestamp]&.to_date
documentation_status = api.membership.dig(:profile, :status).to_s
reasons = []
reasons << :expired unless valid_until && valid_until >= Date.current
reasons << :documentation_not_ok unless documentation_status == APPROVED_DOCUMENTATION_STATUS
user.merge(reasons: reasons, valid_until: valid_until, documentation_status: documentation_status)
end
rescue => error
user.merge(reasons: [:citrg_unavailable], detail: "#{error.class}: #{error.message}")
end
collected << row
lock.synchronize do
counter += 1
print "\rchecked: #{counter}/#{targets.size}" if (counter % 10).zero?
end
end end end
workers.each(&:join) puts “\rchecked: #{targets.size}/#{targets.size}”
rows = [] rows « collected.pop until collected.empty?
eligible = rows.select { |row| row[:reasons].empty? } undecided = rows.select { |row| row[:reasons] == [:citrg_unavailable] } ineligible = rows - eligible - undecided
puts “\n=== SUMMARY ===” puts format(“%-22s %d”, “eligible”, eligible.size) puts format(“%-22s %d”, “ineligible”, ineligible.size) puts format(“%-22s %d”, “undecided”, undecided.size) puts “\n=== REASONS (a therapist can have more than one) ===” ineligible.flat_map { |row| row[:reasons] }.tally.sort_by { |_, count| -count }.each do |reason, count| puts format(“%-22s %d”, reason, count) end
upcoming = Meeting.scheduled.where(start_at: Time.current.beginning_of_day..) ineligible_by_id = ineligible.index_by { |row| row[:id] }
via_client = upcoming.joins(:user_client) .where(user_clients: {professional_id: ineligible_by_id.keys}) .pluck(“user_clients.professional_id”, “meetings.id”, “meetings.start_at”)
via_participant = upcoming.joins(:meeting_participants) .where(meeting_participants: { user_id: ineligible_by_id.keys, person_type: MeetingParticipant.person_types[:professional] }) .pluck(“meeting_participants.user_id”, “meetings.id”, “meetings.start_at”)
sessions_by_therapist = (via_client + via_participant) .uniq { |therapist_id, meeting_id, _| [therapist_id, meeting_id] } .group_by { |therapist_id, _, _| therapist_id }
if ineligible.any? puts “\n=== INELIGIBLE (#{ineligible.size}) ===” puts format(“%-8s %-38s %-12s %-10s %-8s %-17s %s”, “ID”, “EMAIL”, “VALID_UNTIL”, “DOC”, “SESSIONS”, “NEXT”, “REASONS”) ineligible.sort_by { |row| -sessions_by_therapist.fetch(row[:id], []).size }.each do |row| sessions = sessions_by_therapist.fetch(row[:id], []) next_at = sessions.map { |_, _, start_at| start_at }.min
puts format("%-8s %-38s %-12s %-10s %-8s %-17s %s",
row[:id],
row[:email].to_s[0, 37],
row[:valid_until] || "-",
row[:documentation_status] || "-",
sessions.size,
next_at ? next_at.strftime("%d/%m/%Y %H:%M") : "-",
row[:reasons].join(",")) end end
puts “\nwith sessions today or later: #{sessions_by_therapist.size} therapists, #{sessions_by_therapist.values.sum(&:size)} sessions”
if undecided.any? puts “\n=== UNDECIDED — CITRG DID NOT ANSWER (#{undecided.size}) — DO NOT ACT ===” undecided.sort_by { |row| row[:id] }.each do |row| puts format(“%-8s %-40s %s”, row[:id], row[:email], row[:detail]) end end
INELIGIBLE = ineligible puts “\nphase 2 input: INELIGIBLE (#{INELIGIBLE.size} therapists)” ```
Fase 2 — rebaixar para regular
DRY_RUN = true imprime o que mudaria sem escrever nada. Troque para false para aplicar.
```ruby DRY_RUN = true
regular_plan = SubscriptionPlan.regular_therapist applied = [] failed = []
INELIGIBLE.each do |row| user = User.find(row[:id]) pro = user.subscriptions.non_expired.find_by( subscription_plan: SubscriptionPlan.pro, status: [:pending, :active] ) has_regular = user.subscriptions.exists?(subscription_plan: regular_plan) action = has_regular ? “reactivate regular” : “create regular”
if DRY_RUN puts format(“%-8s %-40s pro=%-8s %s”, user.id, user.email, pro&.status || “none”, action) next end
begin Subscriptions::Pro::ExpireWhenIneligible.call(user: user, pro_eligible: false) Subscriptions::RegularTerapeuta::Creation::CreateSubscription.call(user: user, pro_eligible: false) unless has_regular SearchTherapists::EnqueueRefreshByProfile.call(user.id)
user.reload
applied << {id: user.id, email: user.email, plan: user.plan&.slug, status: user.subscription&.status}
puts format("%-8s %-40s -> %s", user.id, user.email, user.plan&.slug || "NO PLAN") rescue => error
failed << {id: user.id, email: user.email, error: "#{error.class}: #{error.message}"}
puts format("%-8s %-40s FAILED %s", user.id, user.email, error.message) end end
if DRY_RUN puts “\nDRY RUN — nothing written. Set DRY_RUN = false to apply.” else puts “\n=== RESULT ===” puts “downgraded: #{applied.size}” puts “failed: #{failed.size}” failed.each { |row| puts format(“%-8s %-40s %s”, row[:id], row[:email], row[:error]) }
leftover = applied.reject { |row| row[:plan] == SubscriptionPlan::REGULAR_THERAPIST_PLAN_SLUG } puts “\nNOT on regular-terapeuta after downgrade: #{leftover.size}” leftover.each { |row| puts format(“%-8s %-40s plan=%s status=%s”, row[:id], row[:email], row[:plan] || “none”, row[:status]) } end ```
Troubleshooting
DRY_RUNeINELIGIBLEsão constantes de topo; reexecutar uma fase imprime o avisoalready initialized constant, que é inofensivo.- A fase 2 nunca consulta o CITRG de novo — ela age sobre a lista da fase 1. Se passar muito tempo entre as fases, rode a fase 1 novamente.
- Linhas
undecidedsão excluídas deINELIGIBLEpor construção, então uma indisponibilidade do CITRG não pode causar rebaixamento. - Terapeutas rebaixados por engano são restaurados no próximo login pelo
auto_subscribe_pro.