feat(catalog): OEM uyumluluk oylaması + Parça Uzmanları liderlik tablosu

Parça satırlarına topluluk oyu eklendi: her OEM kodu için uyumlu/uyumsuz
ikilisi (👍/👎, sayaçlı). Oylar oem_votes'a (kullanıcı+kod başına tek oy,
fikir değişikliği günceller, puan üretmez), ödüller oem_vote_points
ledger'ına yazılır: oy +1, kesin çoğunlukla aynı yönde +2 (kodu ilk
oylayan her zaman 3 alır); ödüller oy anında kesinleşir, çoğunluk sonra
dönse de geri alınmaz. Aynı koda eşzamanlı oylar advisory lock ile
sıralanır.

/dashboard/uzmanlar: Trophy Gamification UI Kit'ten (ui.trophy.so, MIT)
uyarlanan kürsü + sıralama + puan rozetiyle "Parça Uzmanları" liderlik
sayfası; adlar KVKK-maskeli (S*** Y***), cevap kullanıcı id sızdırmaz.

Not: sidebar nav linki, tr/en i18n anahtarları ve routeTree 09a9487'de
gitmişti; bu commit eksik kalan rota/bileşen/API dosyalarını tamamlayarak
dev build'ini düzeltir. Migration: 0015_oem_votes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:00:51 +03:00
parent 09a9487564
commit 8fddd09087
17 changed files with 1170 additions and 2 deletions

View File

@@ -0,0 +1,37 @@
-- OEM community voting + gamification ledger.
-- oem_votes: one row per (user, oem_code) uyumlu/uyumsuz verdict cast from the
-- catalog part rows. Votes pool globally per code; part/vehicle/category are
-- analytics breadcrumbs only (catalog routes pass catalog_vehicles ids → no FK,
-- mirrors oem_code_copies). Re-votes update the row in place.
-- oem_vote_points: append-only, exactly one row per vote (vote_id unique),
-- written in the same transaction. points = 1 (vote) + 2 (agreed with strict
-- majority at cast time; first voter always 3). Never retro-adjusted.
CREATE TABLE "oem_votes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"oem_code" varchar(100) NOT NULL,
"vote" varchar(12) NOT NULL,
"part_id" uuid,
"vehicle_id" uuid,
"category_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "oem_vote_points" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"vote_id" uuid NOT NULL,
"oem_code" varchar(100) NOT NULL,
"points" integer NOT NULL,
"correct" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "oem_votes" ADD CONSTRAINT "oem_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_vote_id_oem_votes_id_fk" FOREIGN KEY ("vote_id") REFERENCES "public"."oem_votes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "oem_votes_user_oem_idx" ON "oem_votes" USING btree ("user_id","oem_code");--> statement-breakpoint
CREATE INDEX "oem_votes_oem_code_idx" ON "oem_votes" USING btree ("oem_code");--> statement-breakpoint
CREATE UNIQUE INDEX "oem_vote_points_vote_id_idx" ON "oem_vote_points" USING btree ("vote_id");--> statement-breakpoint
CREATE INDEX "oem_vote_points_user_id_idx" ON "oem_vote_points" USING btree ("user_id");

View File

@@ -106,6 +106,13 @@
"when": 1781136000000,
"tag": "0014_proxy_logs",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1781222400000,
"tag": "0015_oem_votes",
"breakpoints": true
}
]
}

View File

@@ -34,6 +34,7 @@ import { InternalAdminModule } from "./internal-admin/internal-admin.module";
import { JobsModule } from "./jobs/jobs.module";
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
import { NotificationsModule } from "./notifications/notifications.module";
import { OemVotesModule } from "./oem-votes/oem-votes.module";
import { PartsModule } from "./parts/parts.module";
import { PaymentsModule } from "./payments/payments.module";
import { PlansModule } from "./plans/plans.module";
@@ -93,6 +94,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
TranslationsModule,
AdminModule,
AnalyticsModule,
OemVotesModule,
CatalogModule,
ChangelogModule,
ChatwootModule,

View File

@@ -655,3 +655,59 @@ export const proxyLogs = pgTable(
index("proxy_logs_banned_created_at_idx").on(table.banned, table.createdAt),
],
);
// ─── OEM Votes (community compatibility verdicts) ─────
// One row per (user, oemCode): a parts seller's uyumlu/uyumsuz verdict on an
// OEM code, cast from the catalog part rows. Votes pool globally per code —
// the part/vehicle/category columns are analytics breadcrumbs only (mirrors
// oem_code_copies: catalog routes pass catalog_vehicles ids here, so no FK).
// Re-voting updates `vote` in place; points are only ever granted on the
// first insert (see oem_vote_points).
export const oemVotes = pgTable(
"oem_votes",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
// compatible | incompatible
vote: varchar("vote", { length: 12 }).notNull(),
partId: uuid("part_id"),
vehicleId: uuid("vehicle_id"),
categoryId: uuid("category_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_votes_user_oem_idx").on(table.userId, table.oemCode),
index("oem_votes_oem_code_idx").on(table.oemCode),
],
);
// ─── OEM Vote Points (gamification ledger) ────────────
// Append-only: exactly one row per oem_votes row (vote_id unique), written in
// the same transaction as the vote insert. `points` = 1 for voting +2 when
// the vote agreed with the strict majority at cast time (first voter on a
// code always agrees with themselves → 3). Awards are never retro-adjusted
// when the majority later flips. Leaderboard = SUM(points) per user.
export const oemVotePoints = pgTable(
"oem_vote_points",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
voteId: uuid("vote_id")
.notNull()
.references(() => oemVotes.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
points: integer("points").notNull(),
correct: boolean("correct").default(false).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_vote_points_vote_id_idx").on(table.voteId),
index("oem_vote_points_user_id_idx").on(table.userId),
],
);

View File

@@ -0,0 +1,36 @@
import { BadRequestException, Body, Controller, Get, Post } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { castOemVoteSchema, lookupOemVotesSchema } from "./oem-votes.dto";
import { OemVotesService } from "./oem-votes.service";
@Controller("oem-votes")
export class OemVotesController {
constructor(private readonly oemVotesService: OemVotesService) {}
// 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) {
const parsed = castOemVoteSchema.safeParse(body);
if (!parsed.success) {
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz oy verisi");
}
return this.oemVotesService.castVote(userId, parsed.data);
}
// Kod listesi URL sınırına sığmayacak kadar uzayabildiği için POST (bkz. /p/matched).
@Post("lookup")
async lookup(@CurrentUser("id") userId: string, @Body() body: unknown) {
const parsed = lookupOemVotesSchema.safeParse(body);
if (!parsed.success) {
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz kod listesi");
}
return { votes: await this.oemVotesService.lookup(userId, parsed.data.codes) };
}
@Get("leaderboard")
async leaderboard(@CurrentUser("id") userId: string) {
return this.oemVotesService.leaderboard(userId);
}
}

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
export const castOemVoteSchema = z.object({
oemCode: z.string().trim().min(2, "OEM kodu geçersiz").max(100, "OEM kodu çok uzun"),
vote: z.enum(["compatible", "incompatible"]),
// Analitik bağlamı — katalog rotaları catalog_vehicles id'si yollayabilir,
// UUID olmayan değerler serviste sessizce düşürülür (oy yine kaydedilir).
partId: z.string().max(100).optional(),
vehicleId: z.string().max(100).optional(),
categoryId: z.string().max(100).optional(),
});
export type CastOemVoteInput = z.infer<typeof castOemVoteSchema>;
export const lookupOemVotesSchema = z.object({
codes: z.array(z.string().trim().min(1).max(100)).min(1).max(400),
});
export type LookupOemVotesInput = z.infer<typeof lookupOemVotesSchema>;

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { computeVoteAward, maskExpertName } from "./oem-votes.logic";
describe("computeVoteAward", () => {
it("ilk oy: kimse oylamamışsa 3 puan (1 oy + 2 doğru)", () => {
expect(computeVoteAward({ compatible: 0, incompatible: 0 }, "compatible")).toEqual({
points: 3,
correct: true,
});
expect(computeVoteAward({ compatible: 0, incompatible: 0 }, "incompatible")).toEqual({
points: 3,
correct: true,
});
});
it("1 kişi uyumlu demişken uyumsuz diyen 1 puan, uyumlu diyen 3 puan alır", () => {
const prior = { compatible: 1, incompatible: 0 };
expect(computeVoteAward(prior, "incompatible")).toEqual({ points: 1, correct: false });
expect(computeVoteAward(prior, "compatible")).toEqual({ points: 3, correct: true });
});
it("beraberliği bozan oy çoğunluğu kendi tarafına çevirdiği için 3 puan alır", () => {
expect(computeVoteAward({ compatible: 1, incompatible: 1 }, "compatible")).toEqual({
points: 3,
correct: true,
});
});
it("açık çoğunluğa karşı oy yalnız taban puanı alır", () => {
expect(computeVoteAward({ compatible: 5, incompatible: 1 }, "incompatible")).toEqual({
points: 1,
correct: false,
});
expect(computeVoteAward({ compatible: 5, incompatible: 1 }, "compatible")).toEqual({
points: 3,
correct: true,
});
});
it("oyumla beraberlik oluşuyorsa çoğunluk sağlanmaz, 1 puan", () => {
// 2-1 iken uyumsuz oyu → 2-2: kesin çoğunluk yok.
expect(computeVoteAward({ compatible: 2, incompatible: 1 }, "incompatible")).toEqual({
points: 1,
correct: false,
});
});
});
describe("maskExpertName", () => {
it("ad ve soyadın yalnız ilk harfini açık bırakır", () => {
expect(maskExpertName("Semih Yılmaz")).toBe("S*** Y***");
});
it("ara adları düşürür, soyad olarak son kelimeyi alır", () => {
expect(maskExpertName("Ali Rıza Demir")).toBe("A*** D***");
});
it("tek kelimelik adı maskeler", () => {
expect(maskExpertName("Semih")).toBe("S***");
});
it("Türkçe karakterleri doğru büyütür", () => {
expect(maskExpertName("ismail çelik")).toBe("İ*** Ç***");
});
it("boş ada güvenli düşer", () => {
expect(maskExpertName(" ")).toBe("Üye");
});
});

View File

@@ -0,0 +1,37 @@
export type OemVoteChoice = "compatible" | "incompatible";
export interface VoteCounts {
compatible: number;
incompatible: number;
}
export interface VoteAward {
points: number;
correct: boolean;
}
export const VOTE_BASE_POINTS = 1;
export const VOTE_MAJORITY_BONUS = 2;
// "Doğru oy" = kendi oyu da sayıldığında kesin çoğunlukla aynı tarafta olmak.
// İlk oy (0-0 → 1-0) ve beraberliği bozan oy çoğunluğu kendi tarafına
// çevirdiği için doğrudur (3 puan); mevcut çoğunluğa karşı oy yalnız taban
// puanı alır (1). Ödül oy anında kesinleşir, çoğunluk sonradan dönse bile
// geriye dönük düzeltilmez.
export function computeVoteAward(prior: VoteCounts, vote: OemVoteChoice): VoteAward {
const mine = (vote === "compatible" ? prior.compatible : prior.incompatible) + 1;
const other = vote === "compatible" ? prior.incompatible : prior.compatible;
const correct = mine > other;
return { points: VOTE_BASE_POINTS + (correct ? VOTE_MAJORITY_BONUS : 0), correct };
}
// 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.
export function maskExpertName(fullName: string): string {
const words = fullName.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "Üye";
const maskWord = (w: string) => `${w.charAt(0).toLocaleUpperCase("tr-TR")}***`;
if (words.length === 1) return maskWord(words[0]);
return `${maskWord(words[0])} ${maskWord(words[words.length - 1])}`;
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { OemVotesController } from "./oem-votes.controller";
import { OemVotesService } from "./oem-votes.service";
@Module({
controllers: [OemVotesController],
providers: [OemVotesService],
exports: [OemVotesService],
})
export class OemVotesModule {}

View File

@@ -0,0 +1,200 @@
import { Inject, Injectable } from "@nestjs/common";
import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { oemVotePoints, oemVotes, users } from "../database/schema/core";
import {
type OemVoteChoice,
type VoteCounts,
computeVoteAward,
maskExpertName,
} from "./oem-votes.logic";
type Tx = Parameters<Parameters<Database["transaction"]>[0]>[0];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidOrNull = (value?: string) => (value && UUID_RE.test(value) ? value : null);
export interface CastVoteResult {
oemCode: string;
myVote: OemVoteChoice;
counts: VoteCounts;
pointsAwarded: number;
correct: boolean | null;
isNew: boolean;
}
export interface OemVoteSummary extends VoteCounts {
myVote: OemVoteChoice | null;
}
export interface LeaderboardEntry {
rank: number;
name: string;
points: number;
votes: number;
isMe: boolean;
}
@Injectable()
export class OemVotesService {
constructor(@Inject(DATABASE) private db: Database) {}
async castVote(
userId: string,
input: {
oemCode: string;
vote: OemVoteChoice;
partId?: string;
vehicleId?: string;
categoryId?: string;
},
): Promise<CastVoteResult> {
const oemCode = input.oemCode.trim();
const context = {
partId: uuidOrNull(input.partId),
vehicleId: uuidOrNull(input.vehicleId),
categoryId: uuidOrNull(input.categoryId),
};
return this.db.transaction(async (tx) => {
// Aynı kod üzerindeki eşzamanlı oyları sıraya sok: ilk-oy bonusu ve
// çoğunluk hesabı yarışsız, deterministik kalır. Kod bazlı kilit —
// farklı kodlar birbirini bekletmez, tx sonunda otomatik bırakılır.
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${`oem-vote:${oemCode}`}))`);
const [existing] = await tx
.select({ id: oemVotes.id, vote: oemVotes.vote })
.from(oemVotes)
.where(and(eq(oemVotes.userId, userId), eq(oemVotes.oemCode, oemCode)))
.limit(1);
if (existing) {
// Fikir değişikliği oyu günceller ama puan üretmez (puan çiftliği yok).
if (existing.vote !== input.vote) {
await tx
.update(oemVotes)
.set({ vote: input.vote, ...context, updatedAt: new Date() })
.where(eq(oemVotes.id, existing.id));
}
return {
oemCode,
myVote: input.vote,
counts: await this.countVotes(tx, oemCode),
pointsAwarded: 0,
correct: null,
isNew: false,
};
}
const prior = await this.countVotes(tx, oemCode);
const award = computeVoteAward(prior, input.vote);
const [inserted] = await tx
.insert(oemVotes)
.values({ userId, oemCode, vote: input.vote, ...context })
.returning({ id: oemVotes.id });
await tx.insert(oemVotePoints).values({
userId,
voteId: inserted.id,
oemCode,
points: award.points,
correct: award.correct,
});
return {
oemCode,
myVote: input.vote,
counts: {
compatible: prior.compatible + (input.vote === "compatible" ? 1 : 0),
incompatible: prior.incompatible + (input.vote === "incompatible" ? 1 : 0),
},
pointsAwarded: award.points,
correct: award.correct,
isNew: true,
};
});
}
// Parça listesi açılırken görünen kodların oy özetleri — tek toplu sorgu.
async lookup(userId: string, codes: string[]): Promise<Record<string, OemVoteSummary>> {
const unique = [...new Set(codes.map((c) => c.trim()).filter(Boolean))];
if (unique.length === 0) return {};
const [tallies, mine] = await Promise.all([
this.db
.select({ oemCode: oemVotes.oemCode, vote: oemVotes.vote, total: count() })
.from(oemVotes)
.where(inArray(oemVotes.oemCode, unique))
.groupBy(oemVotes.oemCode, oemVotes.vote),
this.db
.select({ oemCode: oemVotes.oemCode, vote: oemVotes.vote })
.from(oemVotes)
.where(and(eq(oemVotes.userId, userId), inArray(oemVotes.oemCode, unique))),
]);
const result: Record<string, OemVoteSummary> = {};
const entry = (code: string) => {
result[code] ??= { compatible: 0, incompatible: 0, myVote: null };
return result[code];
};
for (const row of tallies) {
const summary = entry(row.oemCode);
if (row.vote === "compatible") summary.compatible = row.total;
else summary.incompatible = row.total;
}
for (const row of mine) {
entry(row.oemCode).myVote = row.vote as OemVoteChoice;
}
return result;
}
// Parça Uzmanları: puana göre herkes, adlar maskeli ("S*** Y***"). Yanıt
// userId sızdırmaz; istek sahibi kendi satırını isMe ile bulur.
async leaderboard(userId: string): Promise<{
entries: LeaderboardEntry[];
me: { rank: number | null; points: number; votes: number };
}> {
const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`;
const rows = await this.db
.select({
userId: oemVotePoints.userId,
name: users.name,
points: totalPoints,
votes: count(),
})
.from(oemVotePoints)
.innerJoin(users, eq(users.id, oemVotePoints.userId))
.groupBy(oemVotePoints.userId, users.name)
.orderBy(desc(totalPoints), sql`min(${oemVotePoints.createdAt}) asc`)
.limit(500);
const entries = rows.map((row, i) => ({
rank: i + 1,
name: maskExpertName(row.name),
points: row.points,
votes: row.votes,
isMe: row.userId === userId,
}));
const my = entries.find((e) => e.isMe);
return {
entries,
me: { rank: my?.rank ?? null, points: my?.points ?? 0, votes: my?.votes ?? 0 },
};
}
private async countVotes(tx: Tx, oemCode: string): Promise<VoteCounts> {
const rows = await tx
.select({ vote: oemVotes.vote, total: count() })
.from(oemVotes)
.where(eq(oemVotes.oemCode, oemCode))
.groupBy(oemVotes.vote);
const counts: VoteCounts = { compatible: 0, incompatible: 0 };
for (const row of rows) {
if (row.vote === "compatible") counts.compatible = row.total;
else counts.incompatible = row.total;
}
return counts;
}
}