Skip to content

fix(employer): enforce plan tier from persisted employer record - #19

Merged
merlik787-droi merged 3 commits into
Kqirox:mainfrom
P3az3:fix/issue-9-employer-plan-persistence
Aug 19, 2026
Merged

fix(employer): enforce plan tier from persisted employer record#19
merlik787-droi merged 3 commits into
Kqirox:mainfrom
P3az3:fix/issue-9-employer-plan-persistence

Conversation

@P3az3

@P3az3 P3az3 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #9

getEmployerPlan read the billing tier straight from the x-employer-plan request header, so any employer could send x-employer-plan: enterprise and receive the largest search page cap and pass the contactCandidate 402 paywall. This PR persists the plan on the User model (default starter) and resolves it from the authenticated employer's database row, removing the header as an authorization input entirely.

Why

There was no durable plan source to enforce against: no Prisma column stored a plan, and both searchTalent and contactCandidate independently re-derived the tier from the request. The fix adds a single persisted source of truth and centralizes resolution in one getEmployerPlan helper so the two endpoints cannot drift again. A missing profile/row falls back to starter (fail closed for paid features), so the spoofed-header path now gets exactly the starter limits.

What was built

File What it contains
prisma/schema.prisma Adds plan String @default("starter") to the User model.
prisma/migrations/20260819140000_add_employer_plan/migration.sql ALTER TABLE "users" ADD COLUMN "plan" TEXT NOT NULL DEFAULT 'starter'.
src/controllers/employer.controller.ts getEmployerPlan is now async and reads prisma.user.findUnique({ where: { id: req.user.id }, select: { plan: true } }); header parsing removed. searchTalent and contactCandidate await it.
docs/API.md Documents that the per-plan limit comes from the persisted plan, not a header.
tests/unit/employer.controller.test.ts (+ integrations/ mirror) Rewritten to resolve plans from a mocked persisted row and assert header spoofing has no effect.

Integration changes outside src/controllers/employer.controller.ts

  • prisma/schema.prisma — one new column on User (with migration).
  • docs/API.md — employer search auth note updated.
  • tests/unit/employer.controller.test.ts / integrations/unit/employer.controller.test.ts — tests rewritten for persistence.

Acceptance criteria coverage

Enforcement

  • searchTalent applies the per-plan maxLimit from the authenticated employer's persisted plan, not from the request header. (getEmployerPlan reads prisma.user.findUnique; searchTalent uses the resolved plan for PLAN_MAX_SEARCH_LIMIT)
  • contactCandidate returns 402 for a starter employer and succeeds for a pro/enterprise employer based on the persisted plan. (contactCandidate awaits the persisted plan before the PLAN_RANK gate)
  • Setting x-employer-plan: enterprise without a corresponding persisted plan does not raise the limit or bypass the 402. (employer.controller.test.ts — spoofed-header tests for both endpoints)

Persistence

  • Employer plan is stored in Postgres with a default of starter, with a migration. (prisma/schema.prisma + prisma/migrations/20260819140000_add_employer_plan/migration.sql)

Tests

  • Tests cover starter/pro/enterprise plan resolution from persistence, and assert that a spoofed header has no effect. (tests/unit/employer.controller.test.ts — 8 tests incl. enterprise limit, starter+spoofed-header limit, starter+spoofed-header 402)

Documentation

  • docs/API.md and the Swagger annotations reflect the new plan source and remove the header from the documented inputs. (docs/API.md updated; employer.controller.ts has no @openapi annotations and no longer references the header anywhere)

Test plan

  • pnpm test:ci — 281/281 passing (3 new employer-plan tests)
  • pnpm exec tsc --noEmit — no type errors
  • pnpm lint — no errors or warnings
  • pnpm build — succeeds
  • npx prisma generate — client regenerated (ran with DATABASE_URL set)

Env vars / Notes

No new environment variables or config keys. The migration targets the users table name declared by the current @@map("users") on the User model. Note the repository's committed migration history (a single 20260307143903_init) predates the current schema.prisma and uses unmapped table names; that pre-existing drift is out of scope for this issue. The role-model defect (no EMPLOYER in the Prisma Role enum) remains a separate, blocking-adjacent issue (#4) and is intentionally not addressed here.

Replace the client-controlled x-employer-plan header with a persisted
plan field on the User model (default starter). searchTalent and
contactCandidate now resolve the plan from the authenticated employer's
database row, so the per-plan page cap and the pro/enterprise contact
gate can no longer be bypassed by a spoofed header. Adds a Prisma
migration and tests for starter/pro/enterprise resolution and header
spoofing.

@merlik787-droi merlik787-droi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@P3az3 resolve conflicts please.

P3az3 added 2 commits August 19, 2026 15:22
Use the uppercase EMPLOYER role in the persisted-plan test fixtures to
match the unified role enum from Kqirox#14, and mock prisma.quizQuestion.findMany
in the module completion webhook test now that completeModule grades
against server-side answer keys (Kqirox#16).
@P3az3

P3az3 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

@merlik787-droi conflicts resolved, kindly review.

@merlik787-droi merlik787-droi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@merlik787-droi
merlik787-droi merged commit 57bc2e0 into Kqirox:main Aug 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Employer plan tier trusts a client-supplied header: per-plan limits and paid contact gating are bypassable

2 participants