Envia o motivo de bloqueio já traduzido no payload do apolo_membership (v2)

TLDR: GET /api/v2/apolo_membership (endpoint novo) passa a enviar apolo_access_denial_reason — texto já traduzido via I18n, preenchido só quando apolo_access_status é false — para que o Apolo, ao migrar para o v2, possa gatear os cursos exclusivos is_cbtrg pelo booleano (hoje apolo_access_enabled, do lado do Apolo) em vez de só pela data valid_until_timestamp. O GET /api/v1/apolo_membership — consumido hoje em produção pelo Apolo — não muda: continua devolvendo exatamente o payload de antes desta spec, via membership.public_serialize.

Contexto

Hoje o Apolo libera os cursos exclusivos do CITRG (Sala Secreta, Academy, Mentoria Avançada) verificando só se cbtrg_expires_at (a data de vigência, que ele recebe do CITRG como valid_until_timestamp) ainda está no futuro. Essa data reflete a vigência da filiação, mas não reflete se a documentação da renovação foi aprovada — por isso um terapeuta que renovou e está com a documentação pendente de aprovação continua com os cursos exclusivos liberados, mesmo sem estar regular.

O CITRG já calcula a regularidade real e envia isso hoje como apolo_access_status — o mesmo booleano que os cursos comuns do IBFT já usam para liberar acesso (do lado do Apolo, esse valor já é persistido localmente como apolo_access_enabled). O ajuste necessário do lado do Apolo é: estender esse mesmo gate aos cursos is_cbtrg, parando de depender só da data. Essa parte da mudança é do repositório do Apolo e foi comunicada à equipe deles por documento técnico separado (Documentacao-Cursos-Exclusivos-CITRG-Apolo.pdf, entregue fora deste repositório) — não é implementada aqui.

O que cabe ao CITRG, e é o que esta spec cobre: quando apolo_access_status é false, o Apolo hoje não tem nenhuma informação de por quê — só o booleano. Para o Apolo poder exibir uma mensagem ao usuário sem precisar manter um enum de motivos e uma tabela de tradução própria, o CITRG passa a resolver e traduzir o texto antes de enviar: o Apolo só recebe uma string pronta e exibe.

Por que um endpoint v2 novo, em vez de alterar o v1 existente

A primeira versão desta spec implementou o campo direto em GET /api/v1/apolo_membership, o endpoint que o Apolo já consome em produção. Reavaliado: o v1 é um contrato em produção sem controle de versão de payload — qualquer mudança de forma nele é uma mudança de contrato no ar, e o Apolo só vai efetivamente ler apolo_access_denial_reason depois de implementar a leitura desse campo do lado dele (fora deste repositório, ver Documentacao-Cursos-Exclusivos-CITRG-Apolo.pdf). Até essa migração acontecer, o v1 deve continuar bit-a-bit igual ao que está em produção hoje.

O namespace api/v2 já existe (config/routes.rb, app/controllers/api/v2/) para os endpoints mais novos da aplicação. O novo GET /api/v2/apolo_membership recebe a mudança inteira (serializer, campo novo, motivo traduzido); o v1 permanece intocado até o Apolo migrar para o v2.

Por que um serializer dedicado, revertendo a decisão anterior

As duas specs anteriores sobre este mesmo endpoint (20260717120659_fix_apolo_membership_renewal_approval.md e 20260729145634_fix_apolo_access_early_renewal.md) consideraram e descartaram um ApoloMembershipSerializer autônomo, preferindo reaproveitar Membership#public_serialize e sobrescrever só as duas chaves de data — para manter o diff mínimo, já que o payload do Apolo ainda não divergia o suficiente do público para justificar a duplicação.

Essa condição deixou de valer: apolo_access_denial_reason é um campo que só faz sentido para o Apolo (os demais consumidores de public_serialize — MembershipsController#index, me, user_profiles — não têm motivo para expor motivo de bloqueio de curso). Adicionar o campo direto em public_serialize vazaria para todos eles. A extração para MembershipApoloSerializer isola exatamente o payload deste endpoint (agora o v2), ao custo aceito de duplicar a forma do hash entre Api::V1::ApoloMembershipController e Api::V2::ApoloMembershipController (mesmo risco de drift já registrado nas specs anteriores — o v1 mantém sua cópia da lógica de seleção porque não pode depender de um controller v2 que pode mudar independentemente).

Objetivos

  • Criar GET /api/v2/apolo_membership, com a mesma lógica de seleção de filiação do v1 (R-001) e o payload de MembershipApoloSerializer, incluindo apolo_access_denial_reason: nil quando apolo_access_status é true; texto já traduzido quando é false.
  • O motivo reportado deve sempre corresponder exatamente à condição que bloqueou o acesso — sem inferir de novo uma regra separada da de apolo_access_permitted?.
  • Resolver o texto no idioma da requisição (a app já resolve locale por Accept-Language/locale param em ApplicationController#switch_locale), sem exigir nenhuma mudança de infraestrutura de i18n.
  • Restaurar GET /api/v1/apolo_membership ao payload de antes desta spec (membership.public_serialize.merge(...), sem MembershipApoloSerializer) — o Apolo em produção não pode ter o contrato do v1 alterado antes de migrar para o v2.

Fora de escopo

  • Não implementa a mudança do lado do Apolo (gate dos cursos is_cbtrg por apolo_access_enabled em vez da data, e a migração de consumo de v1 para v2). Comunicado por documento externo à equipe do Apolo.
  • Não altera Membership#apolo_access_permitted? nem a seleção de filiação (membership/newest_paid_membership) — ambos já corrigidos nas specs anteriores sobre este endpoint, e replicados como estão no controller v2.
  • Não altera Membership#public_serialize nem afeta MembershipsController#index, me ou user_profiles.
  • Não introduz um enum fechado de motivos no contrato do payload. O valor de apolo_access_denial_reason é texto livre — o Apolo deve só exibi-lo, nunca comparar ou ramificar lógica pelo conteúdo. Internamente o CITRG resolve o texto a partir de um símbolo interno (Membership#apolo_access_denial_reason), mas esse símbolo não é exposto no payload.
  • Não remove ou depreca GET /api/v1/apolo_membership — ele continua no ar, sem mudanças, até o Apolo migrar para o v2.

Mudanças

app/models/membership.rb

Novo método, espelhando a ordem de curto-circuito exata de apolo_access_permitted? para garantir que o motivo reportado sempre corresponda à condição que de fato bloqueou:

```ruby def apolo_access_denial_reason return nil if apolo_access_permitted? return :suspended if suspended? return :not_paid unless paid? return :expired unless valid_until.present? && valid_until >= Date.current

:pending_documentation_approval end ```

app/serializers/membership_apolo_serializer.rb (novo)

Reproduz a forma de public_serialize mais o campo novo, traduzido:

```ruby class MembershipApoloSerializer include ActiveModel::Serialization

DENIAL_REASON_LOCALE_KEYS = { suspended: “api.messages.apolo_access_denial_reasons.suspended”, not_paid: “api.messages.apolo_access_denial_reasons.not_paid”, expired: “api.messages.apolo_access_denial_reasons.expired”, pending_documentation_approval: “api.messages.apolo_access_denial_reasons.pending_documentation_approval” }.freeze

def initialize(membership) @membership = membership end

def as_json(options = {}) { apolo_access_status: @membership.apolo_access_permitted?, apolo_access_denial_reason: denial_reason_text, # …demais campos, idênticos a public_serialize… } end

private

def denial_reason_text reason = @membership.apolo_access_denial_reason return nil unless reason

I18n.t(DENIAL_REASON_LOCALE_KEYS.fetch(reason))   end end ```

app/controllers/api/v1/apolo_membership_controller.rb — revertido

Volta a usar public_serialize, exatamente como estava antes desta spec:

diff def apolo_payload - MembershipApoloSerializer.new(membership).as_json.merge( + membership.public_serialize.merge( valid_until: newest_paid_membership.valid_until.strftime("%m/%Y"), valid_until_timestamp: newest_paid_membership.valid_until ) end

app/controllers/concerns/locale_switching.rb (novo)

switch_locale/resolved_locale/available_locale?/locale_from_header/best_available_locale — extraídos do ApplicationController raiz para um concern (LocaleSwitching, com around_action :switch_locale no included do), para poderem ser reaproveitados sem duplicação por qualquer controller que não herde da raiz.

app/controllers/application_controller.rb

Passa a incluir LocaleSwitching em vez de definir os métodos de locale diretamente. Nenhuma mudança de comportamento — protect_from_forgery, admin_access_denied e os demais métodos (verify_api, current_user_api, get_countries, membership, authenticate_citrg_token) continuam como estavam.

app/controllers/api/v2/application_controller.rb (novo)

Api::V2::ApplicationController, herdando direto de ActionController::Base — não de ApplicationController raiz, para o namespace api/v2 não herdar protect_from_forgery/verify_api/current_user_api/get_countries/membership/authenticate_citrg_token da raiz, que são específicos do contrato v1. Inclui LocaleSwitching e centraliza check_for_header_authorization (checagem do header apolo-access-token) como before_action, já que por ora todo controller que herdar daqui é autenticado dessa forma.

```ruby class Api::V2::ApplicationController < ActionController::Base include LocaleSwitching

before_action :check_for_header_authorization

private

def check_for_header_authorization auth_key = params[:”apolo-access-token”] || request.headers[“apolo-access-token”]

unless auth_key.present?
  render json: { error: I18n.t("api.errors.apolo.missing_token") },
    status: :unauthorized and return
end

return if auth_key == APOLO_ACCESS_TOKEN

render json: { error: I18n.t("api.errors.apolo.invalid_token") },
  status: :unauthorized and return   end end ```

Migração gradual: por ora, só Api::V2::ApoloMembershipController passa a herdar dele. Os demais controllers do namespace (AuthController, MembershipsController, OnboardingController, TherapistController, MembershipValidationsController) continuam herdando direto de ApplicationController raiz — usam outros mecanismos de autenticação (CurrentUserApolo, authenticate_citrg_token), não fazem parte desta mudança, e não migram para Api::V2::ApplicationController enquanto isso não for reavaliado.

app/controllers/api/v2/apolo_membership_controller.rb

Mesmo corpo que o v1 tinha nesta spec antes da reavaliação — Api::V2::ApoloMembershipController, agora herdando de Api::V2::ApplicationController (em vez de ApplicationController direto) e sem o próprio check_for_header_authorization (herdado da base v2), com membership/valid_paid_memberships/newest_paid_membership idênticos ao v1, e apolo_payload usando MembershipApoloSerializer. Api::V1::ApoloMembershipController mantém sua cópia de check_for_header_authorization explícita, sem depender da base v2:

ruby def apolo_payload MembershipApoloSerializer.new(membership).as_json.merge( valid_until: newest_paid_membership.valid_until.strftime("%m/%Y"), valid_until_timestamp: newest_paid_membership.valid_until ) end

config/routes.rb

Nova rota dentro de namespace :v2:

ruby get "/apolo_membership", to: "apolo_membership#show", only: [ :show ]

config/locales/{pt-BR,en,es}.yml

Novo namespace api.messages.apolo_access_denial_reasons, com os 4 motivos traduzidos nos 3 locales já suportados pela aplicação:

yaml apolo_access_denial_reasons: pending_documentation_approval: "..." expired: "..." not_paid: "..." suspended: "..."

Como verificar

bash make run.test path=test/models/membership_test.rb make run.test path=test/serializers/membership_apolo_serializer_test.rb make run.test path=test/controllers/api/v1/apolo_membership_controller_test.rb make run.test path=test/controllers/api/v2/apolo_membership_controller_test.rb

test/controllers/api/v1/apolo_membership_controller_test.rb volta a ficar exatamente como estava antes desta spec (sem asserções de apolo_access_denial_reason) — ele prova que o v1 não mudou. test/controllers/api/v2/apolo_membership_controller_test.rb é novo, espelhando o v1 mais as asserções de apolo_access_denial_reason (o conteúdo que o v1 tinha nesta spec antes da reavaliação).

Executado: 32 + 6 + 9 + 9 testes, respectivamente. Todos verdes em ambos os controllers, exceto o mesmo teste pré-existente e não relacionado em cada um (keeps the current membership on the last day of its validity, flake de borda de travel_to já presente antes desta mudança). make run.lint sem violações.

Manual: chamar GET /api/v2/apolo_membership com um usuário cuja renovação está paga e vigente, mas sem admin_approved_by_id/carteira emitida — apolo_access_status deve vir false e apolo_access_denial_reason deve vir com o texto de “documentação em análise”, no idioma do header Accept-Language enviado. Confirmar também que GET /api/v1/apolo_membership para o mesmo usuário devolve o mesmo payload de antes desta spec (sem a chave apolo_access_denial_reason).

Documentação