Bloquear terapeuta logado de acessar o funil de checkout — Implementation Plan

Spec: .project/docs/specs/20260807163816_block_therapist_checkout_access.md Branch: fix/block-therapist-checkout-access

Goal: impedir que qualquer terapeuta logado (regular ou pro) complete o funil de checkout/pagamento e agende sessão com outro terapeuta, mantendo cliente e visitante anônimo funcionando como hoje.

Architecture: um novo GuardValidator (CheckoutValidator) no routesGuard cobre as 3 páginas do funil (/checkout/{slug}/info, /app/checkout/{slug}/cartao, /app/checkout/{slug}/pix) via regex, bloqueando qualquer sessão logada que não seja isCustomer. Para o validator rodar em /checkout/{slug}/info (hoje fora do middleware), o path entra no matcher do middleware.ts, e o authGuard ganha uma exceção pontual para não forçar login do visitante anônimo nessa página específica.

Tech stack: Next.js Pages Router, next-auth/middleware (withAuth), Jest.

Global constraints

  • Nenhuma mudança de backend/API.
  • Nenhuma diferenciação entre terapeuta regular e pro — bloqueia terapeuta de qualquer tier.
  • Seguir exatamente o padrão de GuardValidator já usado por minhaCarteiraValidator (classe implementando shouldValidate/validate/redirectPath, reexport em index.ts).
  • Commits: uma linha, sem menção a IA, seguindo wehive:commit.

Task 1: CheckoutValidator

Files: - Create: src/infra/routesGuard/validators/checkoutValidator/checkoutValidator.tsx - Create: src/infra/routesGuard/validators/checkoutValidator/index.ts - Test: src/infra/routesGuard/validators/checkoutValidator/checkoutValidator.test.ts

Interfaces: - Consumes: GuardValidator (../types), isCustomer (@/infra/permissionsManager), IUserServerSession/TUserSession (@/services/user/types), EUserSubscriptionSlug (@/services/subscription/types, só no teste). - Produces: CheckoutValidator (default export de checkoutValidator.tsx e de index.ts), consumido pela Task 3 em validators/index.ts.

  • [ ] Step 1: Write the failing test

```ts // src/infra/routesGuard/validators/checkoutValidator/checkoutValidator.test.ts import { NextRequestWithAuth } from “next-auth/middleware”; import { expect } from “@jest/globals”; import CheckoutValidator from “./checkoutValidator”; import { EUserSubscriptionSlug } from “@/services/subscription/types”;

describe(“CheckoutValidator”, () => { let validator: CheckoutValidator;

const createMockRequest = ( pathname: string, subscriptionSlug?: EUserSubscriptionSlug, ) => { return { nextUrl: { pathname, }, url: “http://localhost:3000”, nextauth: { token: { session: subscriptionSlug ? { user: { pid: “user-123” }, subscription: { slug: subscriptionSlug }, } : undefined, uid: “uid-123”, access_token: “token-123”, client: “client-123”, }, }, } as unknown as NextRequestWithAuth; };

beforeEach(() => { validator = new CheckoutValidator(); });

describe(“shouldValidate”, () => { it(“should return true for the 3 checkout funnel routes”, () => { const routes = [ “/checkout/joao-silva/info”, “/app/checkout/joao-silva/cartao”, “/app/checkout/joao-silva/pix”, ];

  routes.forEach((route) => {
    const req = createMockRequest(
      route,
      EUserSubscriptionSlug.REGULAR_CLIENT,
    );
    expect(validator.shouldValidate(req)).toBe(true);
  });
});

it("should return false for unrelated routes", () => {
  const routes = [
    "/app/checkout/joao-silva/sucesso",
    "/app/painel-de-controle",
    "/terapeuta/joao-silva",
  ];

  routes.forEach((route) => {
    const req = createMockRequest(
      route,
      EUserSubscriptionSlug.REGULAR_CLIENT,
    );
    expect(validator.shouldValidate(req)).toBe(false);
  });
});   });

describe(“validate”, () => { it(“should allow access for a customer session”, async () => { const req = createMockRequest( “/checkout/joao-silva/info”, EUserSubscriptionSlug.REGULAR_CLIENT, );

  const isValid = await validator.validate(req);

  expect(isValid).toBe(true);
  expect(validator.redirectPath).toBeUndefined();
});

it("should block a regular therapist session and redirect to buscar-terapeuta", async () => {
  const req = createMockRequest(
    "/checkout/joao-silva/info",
    EUserSubscriptionSlug.REGULAR_TERAPEUTA,
  );

  const isValid = await validator.validate(req);

  expect(isValid).toBe(false);
  expect(validator.redirectPath?.toString()).toBe(
    "http://localhost:3000/buscar-terapeuta",
  );
});

it("should block a pro therapist session and redirect to buscar-terapeuta", async () => {
  const req = createMockRequest(
    "/app/checkout/joao-silva/cartao",
    EUserSubscriptionSlug.PRO,
  );

  const isValid = await validator.validate(req);

  expect(isValid).toBe(false);
  expect(validator.redirectPath?.toString()).toBe(
    "http://localhost:3000/buscar-terapeuta",
  );
});

it("should allow access when there is no session", async () => {
  const req = createMockRequest("/checkout/joao-silva/info");

  const isValid = await validator.validate(req);

  expect(isValid).toBe(true);
  expect(validator.redirectPath).toBeUndefined();
});   }); }); ```
  • [ ] Step 2: Run to verify it fails

bash make run.test path=src/infra/routesGuard/validators/checkoutValidator/checkoutValidator.test.ts

Expected: FAIL — Cannot find module './checkoutValidator'

  • [ ] Step 3: Write minimal implementation

```tsx // src/infra/routesGuard/validators/checkoutValidator/checkoutValidator.tsx import { NextRequestWithAuth } from “next-auth/middleware”; import { GuardValidator } from “../types”; import { IUserServerSession, TUserSession } from “@/services/user/types”; import { isCustomer } from “@/infra/permissionsManager”;

const CHECKOUT_FUNNEL_PATH = /^\/(app\/)?checkout\/[^/]+\/(info cartao pix)$/;

class CheckoutValidator implements GuardValidator { redirectPath?: string | URL;

shouldValidate(req: NextRequestWithAuth): boolean { return CHECKOUT_FUNNEL_PATH.test(req.nextUrl.pathname); }

async validate(req: NextRequestWithAuth) { const { session } = req.nextauth.token as unknown as IUserServerSession;

if (session && !isCustomer({ user: session } as TUserSession)) {
  this.redirectPath = new URL("/buscar-terapeuta", req.url);
  return false;
}

return true;   } }

export default CheckoutValidator; ```

```ts // src/infra/routesGuard/validators/checkoutValidator/index.ts import CheckoutValidator from “./checkoutValidator”;

export default CheckoutValidator; ```

  • [ ] Step 4: Run to verify it passes

bash make run.test path=src/infra/routesGuard/validators/checkoutValidator/checkoutValidator.test.ts

Expected: PASS — 6 testes verdes.

  • [ ] Step 5: Commit

bash git add src/infra/routesGuard/validators/checkoutValidator git commit -m "feat: add checkout validator"


Task 2: Exceção de visitante anônimo no authGuard

Files: - Modify: src/infra/routesGuard/callbacks.ts - Test: src/infra/routesGuard/callbacks.test.ts

Interfaces: - Consumes: nenhuma nova (função authGuard já existente). - Produces: authGuard passa a retornar true para /checkout/{slug}/info mesmo sem token, mantendo o comportamento atual pra todo o resto — consumido pela Task 3 ao adicionar /checkout/:path* no matcher.

  • [ ] Step 1: Write the failing test

```ts // src/infra/routesGuard/callbacks.test.ts import { expect } from “@jest/globals”; import { NextRequest } from “next/server”; import { authGuard } from “./callbacks”;

describe(“authGuard”, () => { const createMockRequest = (pathname: string) => ({ nextUrl: { pathname } }) as NextRequest;

it(“should allow access to /cadastro/certificado-invalido without a token”, () => { const req = createMockRequest(“/cadastro/certificado-invalido”);

expect(authGuard({ token: null, req })).toBe(true);   });

it(“should allow anonymous access to the checkout info page”, () => { const req = createMockRequest(“/checkout/joao-silva/info”);

expect(authGuard({ token: null, req })).toBe(true);   });

it(“should require a token for the checkout payment pages”, () => { const req = createMockRequest(“/app/checkout/joao-silva/cartao”);

expect(authGuard({ token: null, req })).toBe(false);   });

it(“should require a token for other app routes”, () => { const req = createMockRequest(“/app/painel-de-controle”);

expect(authGuard({ token: null, req })).toBe(false);   });

it(“should allow access when a token is present”, () => { const req = createMockRequest(“/app/painel-de-controle”);

expect(authGuard({ token: {} as any, req })).toBe(true);   }); }); ```
  • [ ] Step 2: Run to verify it fails

bash make run.test path=src/infra/routesGuard/callbacks.test.ts

Expected: FAIL — o teste “should allow anonymous access to the checkout info page” falha porque authGuard hoje retorna Boolean(null) (false) para esse path.

  • [ ] Step 3: Write minimal implementation

```ts // src/infra/routesGuard/callbacks.ts import { JWT } from “next-auth/jwt”; import { NextRequest } from “next/server”;

const ANONYMOUS_CHECKOUT_INFO_PATH = /^\/checkout\/[^/]+\/info$/;

export const authGuard = ({ token, req, }: { token: JWT | null; req: NextRequest; }) => { if (req.nextUrl.pathname === “/cadastro/certificado-invalido”) return true; if (ANONYMOUS_CHECKOUT_INFO_PATH.test(req.nextUrl.pathname)) return true;

return Boolean(token); }; ```

  • [ ] Step 4: Run to verify it passes

bash make run.test path=src/infra/routesGuard/callbacks.test.ts

Expected: PASS — 5 testes verdes.

  • [ ] Step 5: Commit

bash git add src/infra/routesGuard/callbacks.ts src/infra/routesGuard/callbacks.test.ts git commit -m "fix: allow anonymous checkout info access"


Task 3: Ligar tudo — matcher e registro do validator

Files: - Modify: middleware.ts - Modify: src/infra/routesGuard/validators/index.ts

Interfaces: - Consumes: CheckoutValidator (Task 1), authGuard já atualizado (Task 2). - Produces: nenhuma nova interface — fecha o fluxo ponta a ponta.

Sem teste automatizado dedicado (mudança de configuração/wiring, sem lógica própria); a cobertura vem dos testes das Tasks 1 e 2 mais a verificação manual do spec.

  • [ ] Step 1: Add /checkout/:path* to the middleware matcher

ts // middleware.ts export const config = { matcher: ["/app/:path*", "/cadastro/:path*", "/checkout/:path*"], };

  • [ ] Step 2: Register CheckoutValidator in getValidators()

```ts // src/infra/routesGuard/validators/index.ts import AssinaturaValidator from “./assinaturaValidator”; import CadastroValidator from “./cadastroValidator/”; import OnboardingValidator from “./onboardingValidator/”; import MeusClientesValidator from “./meusClientesValidator”; import MinhaCarteiraValidator from “./minhaCarteiraValidator”; import MinhaDisponibilidadeValidator from “./minhaDisponibilidadeValidator”; import RestrictedRoutesForRolesValidator from “./restrictedRoutesValidator”; import CheckoutValidator from “./checkoutValidator”;

export const getValidators = () => [ new CadastroValidator(), new OnboardingValidator(), new AssinaturaValidator(), new MeusClientesValidator(), new MinhaCarteiraValidator(), new MinhaDisponibilidadeValidator(), new RestrictedRoutesForRolesValidator(), new CheckoutValidator(), ]; ```

  • [ ] Step 3: Run the full suite

bash make run.test

Expected: PASS — nenhum teste existente quebrado.

  • [ ] Step 4: Manual verification (per spec)
  1. Como visitante anônimo, escolher uma sessão e chegar em /checkout/{slug}/info sem estar logado — deve carregar normalmente.
  2. Abrir nova aba, logar como terapeuta regular, colar a URL de checkout — deve redirecionar para /buscar-terapeuta.
  3. Repetir o passo 2 logado como terapeuta pro — deve redirecionar para /buscar-terapeuta.
  4. Como cliente logado, completar o fluxo até cartao/pix — deve funcionar sem bloqueio.
  • [ ] Step 5: Commit

bash git add middleware.ts src/infra/routesGuard/validators/index.ts git commit -m "fix: block therapists from checkout funnel"


Self-review

  • Spec coverage: as 4 mudanças listadas na spec (middleware.ts, callbacks.ts, novo checkoutValidator, registro em validators/index.ts) — todas cobertas: Task 1 (validator), Task 2 (callback), Task 3 Step 1 e Step 2 (matcher + registro). Critérios de verificação da spec viraram o Step 4 da Task 3.
  • Placeholder scan: nenhum “TBD”/”TODO” — todo código é real e completo.
  • Type consistency: CheckoutValidator implementa GuardValidator (redirectPath?: string | URL, shouldValidate, validate) igual às demais classes já registradas em validators/index.ts; authGuard mantém a assinatura { token: JWT | null; req: NextRequest } já usada em middleware.ts.