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) => (