diff --git a/apps/api/src/oem-suggestions/oem-suggestions.controller.ts b/apps/api/src/oem-suggestions/oem-suggestions.controller.ts
index ca2fead..a4d4d0a 100644
--- a/apps/api/src/oem-suggestions/oem-suggestions.controller.ts
+++ b/apps/api/src/oem-suggestions/oem-suggestions.controller.ts
@@ -1,17 +1,22 @@
import { BadRequestException, Body, Controller, Get, Post, Query } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { CurrentUser } from "../common/decorators/current-user.decorator";
+import { ExpertAccessService } from "../oem-votes/expert-access.service";
import { createOemSuggestionSchema } from "./oem-suggestions.dto";
import { OemSuggestionsService } from "./oem-suggestions.service";
@Controller("oem-suggestions")
export class OemSuggestionsController {
- constructor(private readonly oemSuggestionsService: OemSuggestionsService) {}
+ constructor(
+ private readonly oemSuggestionsService: OemSuggestionsService,
+ private readonly expertAccess: ExpertAccessService,
+ ) {}
// Spam koruması: dakikada en fazla 10 öneri.
@Post()
@Throttle({ default: { limit: 10, ttl: 60_000 } })
async create(@CurrentUser("id") userId: string, @Body() body: unknown) {
+ await this.expertAccess.assert(userId);
const parsed = createOemSuggestionSchema.safeParse(body);
if (!parsed.success) {
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz öneri");
diff --git a/apps/api/src/oem-suggestions/oem-suggestions.module.ts b/apps/api/src/oem-suggestions/oem-suggestions.module.ts
index 47ce35e..a687844 100644
--- a/apps/api/src/oem-suggestions/oem-suggestions.module.ts
+++ b/apps/api/src/oem-suggestions/oem-suggestions.module.ts
@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
+import { OemVotesModule } from "../oem-votes/oem-votes.module";
import { OemSuggestionsController } from "./oem-suggestions.controller";
import { OemSuggestionsService } from "./oem-suggestions.service";
@Module({
+ imports: [OemVotesModule],
controllers: [OemSuggestionsController],
providers: [OemSuggestionsService],
exports: [OemSuggestionsService],
diff --git a/apps/api/src/oem-votes/expert-access.service.ts b/apps/api/src/oem-votes/expert-access.service.ts
new file mode 100644
index 0000000..ff51890
--- /dev/null
+++ b/apps/api/src/oem-votes/expert-access.service.ts
@@ -0,0 +1,48 @@
+import { ForbiddenException, Inject, Injectable } from "@nestjs/common";
+import { and, countDistinct, eq } from "drizzle-orm";
+import { DATABASE, type Database } from "../database/database.provider";
+import { oemCodeCopies, queryLogs } from "../database/schema/core";
+import { PostHogService } from "../posthog/posthog.service";
+import { meetsExpertCriteria } from "./oem-votes.logic";
+
+// Parça Uzmanları programının ana şalteri (PostHog, local-eval). Kapatınca
+// program herkesten gizlenir; yüzdeli rollout gerekirse oradan daraltılır.
+export const EXPERT_PROGRAM_FLAG = "oem-expert-program";
+
+@Injectable()
+export class ExpertAccessService {
+ constructor(
+ @Inject(DATABASE) private db: Database,
+ private readonly posthog: PostHogService,
+ ) {}
+
+ // Erişim = flag AÇIK VE kullanım kriteri: en az 2 FARKLI başarılı VIN
+ // decode + en az 2 FARKLI OEM kodu kopyası (kayıtlı, kataloğu gerçekten
+ // kullanan üyeler). Flag local-eval edilemezse fail-open — kitleyi kriter
+ // zaten daraltır, PostHog kesintisi programı söndürmesin.
+ async check(userId: string): Promise<{ enabled: boolean }> {
+ const flagOn = await this.posthog.isEnabled(EXPERT_PROGRAM_FLAG, userId, true);
+ if (!flagOn) return { enabled: false };
+
+ const [vinRows, copyRows] = await Promise.all([
+ this.db
+ .select({ n: countDistinct(queryLogs.vin) })
+ .from(queryLogs)
+ .where(and(eq(queryLogs.userId, userId), eq(queryLogs.success, true))),
+ this.db
+ .select({ n: countDistinct(oemCodeCopies.oemCode) })
+ .from(oemCodeCopies)
+ .where(eq(oemCodeCopies.userId, userId)),
+ ]);
+
+ return { enabled: meetsExpertCriteria(vinRows[0]?.n ?? 0, copyRows[0]?.n ?? 0) };
+ }
+
+ // Yazma uçları (oy, öneri) UI gizlense bile doğrudan istekle delinemesin.
+ async assert(userId: string): Promise {
+ const { enabled } = await this.check(userId);
+ if (!enabled) {
+ throw new ForbiddenException("Parça Uzmanları programı hesabınızda henüz aktif değil");
+ }
+ }
+}
diff --git a/apps/api/src/oem-votes/oem-votes.controller.ts b/apps/api/src/oem-votes/oem-votes.controller.ts
index ef4e57d..947077b 100644
--- a/apps/api/src/oem-votes/oem-votes.controller.ts
+++ b/apps/api/src/oem-votes/oem-votes.controller.ts
@@ -1,17 +1,28 @@
import { BadRequestException, Body, Controller, Get, Post } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { CurrentUser } from "../common/decorators/current-user.decorator";
+import { ExpertAccessService } from "./expert-access.service";
import { castOemVoteSchema, lookupOemVotesSchema } from "./oem-votes.dto";
import { OemVotesService } from "./oem-votes.service";
@Controller("oem-votes")
export class OemVotesController {
- constructor(private readonly oemVotesService: OemVotesService) {}
+ constructor(
+ private readonly oemVotesService: OemVotesService,
+ private readonly expertAccess: ExpertAccessService,
+ ) {}
+
+ // Program kapısı: flag + kullanım kriteri (web açılışta bir kez sorar).
+ @Get("access")
+ async access(@CurrentUser("id") userId: string) {
+ return this.expertAccess.check(userId);
+ }
// Puan çiftliğine karşı insan-hızı sınırı: dakikada en fazla 30 oy.
@Post()
@Throttle({ default: { limit: 30, ttl: 60_000 } })
async cast(@CurrentUser("id") userId: string, @Body() body: unknown) {
+ await this.expertAccess.assert(userId);
const parsed = castOemVoteSchema.safeParse(body);
if (!parsed.success) {
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz oy verisi");
diff --git a/apps/api/src/oem-votes/oem-votes.logic.spec.ts b/apps/api/src/oem-votes/oem-votes.logic.spec.ts
index 11a4208..c1e3b36 100644
--- a/apps/api/src/oem-votes/oem-votes.logic.spec.ts
+++ b/apps/api/src/oem-votes/oem-votes.logic.spec.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { computeVoteAward, maskExpertName } from "./oem-votes.logic";
+import { computeVoteAward, maskExpertName, meetsExpertCriteria } from "./oem-votes.logic";
describe("computeVoteAward", () => {
it("ilk oy: kimse oylamamışsa 3 puan (1 oy + 2 doğru)", () => {
@@ -67,3 +67,13 @@ describe("maskExpertName", () => {
expect(maskExpertName(" ")).toBe("Üye");
});
});
+
+describe("meetsExpertCriteria", () => {
+ it("en az 2 farklı VIN VE en az 2 farklı OEM kopyası ister", () => {
+ expect(meetsExpertCriteria(2, 2)).toBe(true);
+ expect(meetsExpertCriteria(5, 3)).toBe(true);
+ expect(meetsExpertCriteria(1, 2)).toBe(false);
+ expect(meetsExpertCriteria(2, 1)).toBe(false);
+ expect(meetsExpertCriteria(0, 0)).toBe(false);
+ });
+});
diff --git a/apps/api/src/oem-votes/oem-votes.logic.ts b/apps/api/src/oem-votes/oem-votes.logic.ts
index 28ad2a6..142f41a 100644
--- a/apps/api/src/oem-votes/oem-votes.logic.ts
+++ b/apps/api/src/oem-votes/oem-votes.logic.ts
@@ -25,6 +25,15 @@ export function computeVoteAward(prior: VoteCounts, vote: OemVoteChoice): VoteAw
return { points: VOTE_BASE_POINTS + (correct ? VOTE_MAJORITY_BONUS : 0), correct };
}
+// Program kitle kriteri: kataloğu gerçekten kullanan kayıtlı üyeler —
+// en az 2 FARKLI başarılı VIN decode VE en az 2 FARKLI OEM kodu kopyası.
+export const MIN_DISTINCT_VINS = 2;
+export const MIN_DISTINCT_OEM_COPIES = 2;
+
+export function meetsExpertCriteria(distinctVins: number, distinctOemCopies: number): boolean {
+ return distinctVins >= MIN_DISTINCT_VINS && distinctOemCopies >= MIN_DISTINCT_OEM_COPIES;
+}
+
// Liderlik listesi adları KVKK-dostu: yalnız ad ve soyadın ilk harfi açık
// ("Semih Yılmaz" → "S*** Y***"). Ara adlar tamamen düşer; tek kelimelik
// adlarda o kelimenin ilk harfi kalır.
diff --git a/apps/api/src/oem-votes/oem-votes.module.ts b/apps/api/src/oem-votes/oem-votes.module.ts
index 7b40646..a312db4 100644
--- a/apps/api/src/oem-votes/oem-votes.module.ts
+++ b/apps/api/src/oem-votes/oem-votes.module.ts
@@ -1,10 +1,11 @@
import { Module } from "@nestjs/common";
+import { ExpertAccessService } from "./expert-access.service";
import { OemVotesController } from "./oem-votes.controller";
import { OemVotesService } from "./oem-votes.service";
@Module({
controllers: [OemVotesController],
- providers: [OemVotesService],
- exports: [OemVotesService],
+ providers: [OemVotesService, ExpertAccessService],
+ exports: [OemVotesService, ExpertAccessService],
})
export class OemVotesModule {}
diff --git a/apps/web/src/hooks/use-expert-access.ts b/apps/web/src/hooks/use-expert-access.ts
new file mode 100644
index 0000000..f8b552a
--- /dev/null
+++ b/apps/web/src/hooks/use-expert-access.ts
@@ -0,0 +1,15 @@
+import { api } from "@/lib/api-client";
+import { useQuery } from "@tanstack/react-query";
+
+// Parça Uzmanları programı kapısı: PostHog ana şalteri + kullanım kriteri
+// sunucuda birlikte değerlendirilir; web yalnız sonucu sorar. Kapalıysa
+// program yüzeyleri (nav linki, oy kartı, öneri bölümü, uzmanlar sayfası)
+// hiç render edilmez — kriterler istemciye sızdırılmaz.
+export function useExpertAccess(enabled = true) {
+ return useQuery({
+ queryKey: ["expert-access"],
+ queryFn: () => api.get<{ enabled: boolean }>("/oem-votes/access"),
+ staleTime: 5 * 60_000,
+ enabled,
+ });
+}
diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx
index ffdc118..fe97b02 100644
--- a/apps/web/src/routes/dashboard.tsx
+++ b/apps/web/src/routes/dashboard.tsx
@@ -3,6 +3,7 @@ import { SiteFooter } from "@/components/site-footer";
import { TrialUrgencyBanner } from "@/components/trial-urgency-banner";
import { TrialValueUpsell } from "@/components/trial-value-upsell";
import { useAuth } from "@/hooks/use-auth";
+import { useExpertAccess } from "@/hooks/use-expert-access";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { KEYS_5 } from "@/lib/keys";
@@ -180,6 +181,13 @@ function DashboardLayout() {
});
const hasActivePlan = subData?.subscription?.status === "active";
+ // Parça Uzmanları programı flag+kriter kapısı: kapalıyken nav linki hiç
+ // görünmez (program yüzeyleri sunucu tarafında da kapalıdır).
+ const { data: expertAccess } = useExpertAccess(!!user);
+ const visibleMainMenuItems = mainMenuItems.filter(
+ (item) => item.to !== "/dashboard/uzmanlar" || expertAccess?.enabled === true,
+ );
+
// Redirect unauthenticated users without mutating router state during render.
useEffect(() => {
if (!isLoading && !user) {
@@ -355,7 +363,7 @@ function DashboardLayout() {
return (
<>
- {mainMenuItems.map((item) => (
+ {visibleMainMenuItems.map((item) => (
,
- ): { v?: string; p?: string; vid?: string } => ({
+ validateSearch: (search: Record): { v?: string; p?: string; vid?: string } => ({
v: typeof search.v === "string" && search.v ? search.v : undefined,
p: typeof search.p === "string" && search.p ? search.p : undefined,
vid: typeof search.vid === "string" && search.vid ? search.vid : undefined,
@@ -132,6 +131,10 @@ export const Route = createFileRoute("/dashboard/oem/$code")({
function OemDetailPage() {
const { code } = Route.useParams();
const { v: vehicleLabel, p: partName, vid: vehicleId } = Route.useSearch();
+ // Parça Uzmanları programı kapısı — kapalıyken oy kartı ve öneri bölümü
+ // hiç render edilmez (cross-ref içeriği herkese açık kalır).
+ const { data: expertAccess } = useExpertAccess();
+ const expertEnabled = expertAccess?.enabled === true;
const { data, isLoading, error } = useQuery({
queryKey: ["p-oem", code],
@@ -207,13 +210,15 @@ function OemDetailPage() {
-
+ {expertEnabled && (
+
+ )}
{/* ─── Loading ────────────────────────────────────────────────────── */}
@@ -364,7 +369,7 @@ function OemDetailPage() {
)}
{/* ─── Topluluk muadil önerileri (eşleşme olmasa da toplanır) ──────── */}
- {!isLoading && }
+ {!isLoading && expertEnabled && }
{/* ─── Reverse catalog: your vehicles that use this code ───────────── */}
{catalogVehicles && catalogVehicles.length > 0 && (
diff --git a/apps/web/src/routes/dashboard/uzmanlar.tsx b/apps/web/src/routes/dashboard/uzmanlar.tsx
index 11f1243..5c7b526 100644
--- a/apps/web/src/routes/dashboard/uzmanlar.tsx
+++ b/apps/web/src/routes/dashboard/uzmanlar.tsx
@@ -4,6 +4,7 @@ import {
LeaderboardRankings,
} from "@/components/gamification/leaderboard-rankings";
import { PointsBadge } from "@/components/gamification/points-badge";
+import { useExpertAccess } from "@/hooks/use-expert-access";
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { Card, CardContent, Skeleton } from "@sase/ui";
@@ -50,15 +51,41 @@ export const Route = createFileRoute("/dashboard/uzmanlar")({
const SKELETON_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5"] as const;
function ExpertsPage() {
+ // Program kapısı: flag + kullanım kriteri. Kapalıyken liderlik sorgusu hiç
+ // atılmaz, sayfa kademeli-açılış mesajı gösterir (nav linki de gizlidir;
+ // burası yalnız doğrudan URL ile gelenler için).
+ const { data: access, isLoading: accessLoading } = useExpertAccess();
+ const expertEnabled = access?.enabled === true;
+
const { data, isLoading } = useQuery({
queryKey: ["oem-leaderboard"],
queryFn: () => api.get("/oem-votes/leaderboard"),
+ enabled: expertEnabled,
});
useEffect(() => {
capture("experts_leaderboard_viewed");
}, []);
+ if (!accessLoading && !expertEnabled) {
+ return (
+
+
+
+
+
+ Parça Uzmanları programı kademeli olarak açılıyor.
+
+
+ Program, kataloğu aktif kullanan üyelere otomatik açılır — şase sorgulamaya ve OEM
+ kodlarıyla çalışmaya devam edin, sıranız geldiğinde burada olacak.
+
+
+
+
+ );
+ }
+
const entries = data?.entries ?? [];
const me = data?.me;
@@ -109,7 +136,7 @@ function ExpertsPage() {
- {isLoading ? (
+ {isLoading || accessLoading ? (
{SKELETON_KEYS.map((k) => (