feat(gamification): Parça Uzmanları programı feature-flag + kullanım kriteri kapısı

Program (oylama + muadil önerisi + liderlik) artık iki katmanlı kapının
arkasında: PostHog "oem-expert-program" ana şalteri (local-eval, fail-open)
VE kullanım kriteri — en az 2 FARKLI başarılı VIN decode VE en az 2 FARKLI
OEM kodu kopyası. Karar sunucuda tek noktada (ExpertAccessService.check);
web GET /oem-votes/access ile bir kez sorar, kriterler istemciye sızmaz.

Kapalıyken: sidebar nav linki görünmez, OEM detayındaki oy kartı + öneri
bölümü render edilmez, /dashboard/uzmanlar kademeli-açılış mesajı gösterir
ve liderlik sorgusu atılmaz. Yazma uçları (oy, öneri) sunucu tarafında da
ForbiddenException ile korunur — UI gizlemek tek başına güven sınırı değil.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 11:04:20 +03:00
parent fe35bbd826
commit 6c414f9a59
11 changed files with 159 additions and 18 deletions

View File

@@ -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");

View File

@@ -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],

View File

@@ -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<void> {
const { enabled } = await this.check(userId);
if (!enabled) {
throw new ForbiddenException("Parça Uzmanları programı hesabınızda henüz aktif değil");
}
}
}

View File

@@ -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");

View File

@@ -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);
});
});

View File

@@ -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.

View File

@@ -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 {}

View File

@@ -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,
});
}

View File

@@ -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 (
<>
<NavSection title={t("nav.sectionMain")} collapsed={isCollapsed} />
{mainMenuItems.map((item) => (
{visibleMainMenuItems.map((item) => (
<NavLink
key={item.to}
to={item.to}

View File

@@ -1,5 +1,6 @@
import { OemSuggestionsSection } from "@/components/catalog/oem-suggestions-section";
import { OemVoteCard } from "@/components/catalog/oem-vote-card";
import { useExpertAccess } from "@/hooks/use-expert-access";
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { Badge, Button, Input, Skeleton } from "@sase/ui";
@@ -119,9 +120,7 @@ export const Route = createFileRoute("/dashboard/oem/$code")({
// Katalogdan gelişte taşınan görüntü bağlamı: v = araç etiketi, p = parça
// adı (oy kartında "neyle neyin uyumlu olduğu" satırı), vid = vehicleId
// (oy kaydına analitik bağlam). Doğrudan ziyarette üçü de boş.
validateSearch: (
search: Record<string, unknown>,
): { v?: string; p?: string; vid?: string } => ({
validateSearch: (search: Record<string, unknown>): { 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() {
</p>
</div>
</div>
<OemVoteCard
oemCode={code}
vehicleLabel={vehicleLabel}
partName={partName}
vehicleId={vehicleId}
className="w-full lg:w-96 lg:shrink-0"
/>
{expertEnabled && (
<OemVoteCard
oemCode={code}
vehicleLabel={vehicleLabel}
partName={partName}
vehicleId={vehicleId}
className="w-full lg:w-96 lg:shrink-0"
/>
)}
</header>
{/* ─── Loading ────────────────────────────────────────────────────── */}
@@ -364,7 +369,7 @@ function OemDetailPage() {
)}
{/* ─── Topluluk muadil önerileri (eşleşme olmasa da toplanır) ──────── */}
{!isLoading && <OemSuggestionsSection oemCode={code} />}
{!isLoading && expertEnabled && <OemSuggestionsSection oemCode={code} />}
{/* ─── Reverse catalog: your vehicles that use this code ───────────── */}
{catalogVehicles && catalogVehicles.length > 0 && (

View File

@@ -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<LeaderboardResponse>("/oem-votes/leaderboard"),
enabled: expertEnabled,
});
useEffect(() => {
capture("experts_leaderboard_viewed");
}, []);
if (!accessLoading && !expertEnabled) {
return (
<div className="mx-auto max-w-5xl">
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<Trophy className="size-10 text-muted-foreground/50" />
<p className="text-sm font-medium">
Parça Uzmanları programı kademeli olarak ılıyor.
</p>
<p className="max-w-md text-sm text-muted-foreground">
Program, kataloğu aktif kullanan üyelere otomatik ılır şase sorgulamaya ve OEM
kodlarıyla çalışmaya devam edin, sıranız geldiğinde burada olacak.
</p>
</CardContent>
</Card>
</div>
);
}
const entries = data?.entries ?? [];
const me = data?.me;
@@ -109,7 +136,7 @@ function ExpertsPage() {
</span>
</div>
{isLoading ? (
{isLoading || accessLoading ? (
<div className="space-y-3">
{SKELETON_KEYS.map((k) => (
<Skeleton key={k} className="h-14 w-full" />