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:
37
apps/api/drizzle/0015_oem_votes.sql
Normal file
37
apps/api/drizzle/0015_oem_votes.sql
Normal 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");
|
||||
@@ -106,6 +106,13 @@
|
||||
"when": 1781136000000,
|
||||
"tag": "0014_proxy_logs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1781222400000,
|
||||
"tag": "0015_oem_votes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
36
apps/api/src/oem-votes/oem-votes.controller.ts
Normal file
36
apps/api/src/oem-votes/oem-votes.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
apps/api/src/oem-votes/oem-votes.dto.ts
Normal file
17
apps/api/src/oem-votes/oem-votes.dto.ts
Normal 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>;
|
||||
69
apps/api/src/oem-votes/oem-votes.logic.spec.ts
Normal file
69
apps/api/src/oem-votes/oem-votes.logic.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
37
apps/api/src/oem-votes/oem-votes.logic.ts
Normal file
37
apps/api/src/oem-votes/oem-votes.logic.ts
Normal 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])}`;
|
||||
}
|
||||
10
apps/api/src/oem-votes/oem-votes.module.ts
Normal file
10
apps/api/src/oem-votes/oem-votes.module.ts
Normal 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 {}
|
||||
200
apps/api/src/oem-votes/oem-votes.service.ts
Normal file
200
apps/api/src/oem-votes/oem-votes.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user