Bloquear terapeuta logado de acessar o funil de checkout — Implementation Plan
Spec:
.project/docs/specs/20260807163816_block_therapist_checkout_access.mdBranch: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
GuardValidatorjá usado porminhaCarteiraValidator(classe implementandoshouldValidate/validate/redirectPath, reexport emindex.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
CheckoutValidatoringetValidators()
```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)
- Como visitante anônimo, escolher uma sessão e chegar em
/checkout/{slug}/infosem estar logado — deve carregar normalmente. - Abrir nova aba, logar como terapeuta regular, colar a URL de checkout — deve redirecionar para
/buscar-terapeuta. - Repetir o passo 2 logado como terapeuta pro — deve redirecionar para
/buscar-terapeuta. - 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, novocheckoutValidator, registro emvalidators/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:
CheckoutValidatorimplementaGuardValidator(redirectPath?: string | URL,shouldValidate,validate) igual às demais classes já registradas emvalidators/index.ts;authGuardmantém a assinatura{ token: JWT | null; req: NextRequest }já usada emmiddleware.ts.