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:
@@ -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");
|
||||
|
||||
@@ -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],
|
||||
|
||||
48
apps/api/src/oem-votes/expert-access.service.ts
Normal file
48
apps/api/src/oem-votes/expert-access.service.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
15
apps/web/src/hooks/use-expert-access.ts
Normal file
15
apps/web/src/hooks/use-expert-access.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 açılıyor.
|
||||
</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
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.
|
||||
</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" />
|
||||
|
||||
Reference in New Issue
Block a user