feat(catalog): OEM oyları detay sayfasına taşındı + topluluk muadil önerileri
Parça tablosu sadeleşti: Uyum ve Adet kolonları kalktı, liste açılışındaki toplu istekler (/oem-votes/lookup ve /p/matched) tamamen kaldırıldı. Her OEM kodu artık koşulsuz /dashboard/oem/$code'a linklenir — P eşleşmesi olmayan kodda da sayfa dolu: topluluk oyu kartı, muadil önerileri ve ters katalog. OEM detay sayfası: OemVoteCard (uyumlu/uyumsuz, sayaçlar, puan toast'ı, puanlama özeti + Parça Uzmanları linki) ve OemSuggestionsSection — eşleşme bulunamayan kodlar için kullanıcıdan marka + parça kodu önerisi toplar. Öneriler oem_suggestions tablosunda (kullanıcı+kod+normalize öneri başına tek satır, ON CONFLICT yutulur), markaya+normalize koda göre gruplanıp "× N kullanıcı" rozetiyle listelenir; önerilen kod kendi detayına linklenir. Şimdilik öneri puan kazandırmaz; status kolonu moderasyon kancası. API: oem-suggestions modülü (POST 10/dk throttle, GET ?code=), migration 0016_oem_suggestions. Eski tablo-içi OemVoteButtons bileşeni silindi. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
18
apps/api/drizzle/0016_oem_suggestions.sql
Normal file
18
apps/api/drizzle/0016_oem_suggestions.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Community OEM cross-reference suggestions, collected on the OEM detail page
|
||||
-- ("muadil parça öner": brand + code). Primarily for codes the P snapshot has
|
||||
-- no match for. suggested_code_norm (upper alphanumerics) powers grouping and
|
||||
-- the per-user dedup; status is a moderation hook (active|hidden).
|
||||
CREATE TABLE "oem_suggestions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"oem_code" varchar(100) NOT NULL,
|
||||
"brand" varchar(80) NOT NULL,
|
||||
"suggested_code" varchar(100) NOT NULL,
|
||||
"suggested_code_norm" varchar(100) NOT NULL,
|
||||
"status" varchar(16) DEFAULT 'active' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "oem_suggestions" ADD CONSTRAINT "oem_suggestions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "oem_suggestions_user_oem_code_idx" ON "oem_suggestions" USING btree ("user_id","oem_code","suggested_code_norm");--> statement-breakpoint
|
||||
CREATE INDEX "oem_suggestions_oem_code_idx" ON "oem_suggestions" USING btree ("oem_code");
|
||||
@@ -113,6 +113,13 @@
|
||||
"when": 1781222400000,
|
||||
"tag": "0015_oem_votes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1781308800000,
|
||||
"tag": "0016_oem_suggestions",
|
||||
"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 { OemSuggestionsModule } from "./oem-suggestions/oem-suggestions.module";
|
||||
import { OemVotesModule } from "./oem-votes/oem-votes.module";
|
||||
import { PartsModule } from "./parts/parts.module";
|
||||
import { PaymentsModule } from "./payments/payments.module";
|
||||
@@ -95,6 +96,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
AdminModule,
|
||||
AnalyticsModule,
|
||||
OemVotesModule,
|
||||
OemSuggestionsModule,
|
||||
CatalogModule,
|
||||
ChangelogModule,
|
||||
ChatwootModule,
|
||||
|
||||
@@ -711,3 +711,34 @@ export const oemVotePoints = pgTable(
|
||||
index("oem_vote_points_user_id_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── OEM Suggestions (community cross-references) ─────
|
||||
// Crowd-sourced equivalents for an OEM code: "this code's compatible part is
|
||||
// <brand> <suggestedCode>", collected on the OEM detail page — primarily for
|
||||
// codes the P snapshot can't match. suggestedCodeNorm (upper, alphanumerics
|
||||
// only) powers grouping and the per-user dedup constraint; display keeps the
|
||||
// submitted casing. `status` is a moderation hook (active|hidden) — no rows
|
||||
// are hidden automatically today.
|
||||
export const oemSuggestions = pgTable(
|
||||
"oem_suggestions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
oemCode: varchar("oem_code", { length: 100 }).notNull(),
|
||||
brand: varchar("brand", { length: 80 }).notNull(),
|
||||
suggestedCode: varchar("suggested_code", { length: 100 }).notNull(),
|
||||
suggestedCodeNorm: varchar("suggested_code_norm", { length: 100 }).notNull(),
|
||||
status: varchar("status", { length: 16 }).default("active").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("oem_suggestions_user_oem_code_idx").on(
|
||||
table.userId,
|
||||
table.oemCode,
|
||||
table.suggestedCodeNorm,
|
||||
),
|
||||
index("oem_suggestions_oem_code_idx").on(table.oemCode),
|
||||
],
|
||||
);
|
||||
|
||||
30
apps/api/src/oem-suggestions/oem-suggestions.controller.ts
Normal file
30
apps/api/src/oem-suggestions/oem-suggestions.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { BadRequestException, Body, Controller, Get, Post, Query } from "@nestjs/common";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { createOemSuggestionSchema } from "./oem-suggestions.dto";
|
||||
import { OemSuggestionsService } from "./oem-suggestions.service";
|
||||
|
||||
@Controller("oem-suggestions")
|
||||
export class OemSuggestionsController {
|
||||
constructor(private readonly oemSuggestionsService: OemSuggestionsService) {}
|
||||
|
||||
// Spam koruması: dakikada en fazla 10 öneri.
|
||||
@Post()
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
async create(@CurrentUser("id") userId: string, @Body() body: unknown) {
|
||||
const parsed = createOemSuggestionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz öneri");
|
||||
}
|
||||
return this.oemSuggestionsService.create(userId, parsed.data);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser("id") userId: string, @Query("code") code: string) {
|
||||
const oemCode = (code ?? "").trim();
|
||||
if (oemCode.length < 2 || oemCode.length > 100) {
|
||||
throw new BadRequestException("OEM kodu geçersiz");
|
||||
}
|
||||
return { suggestions: await this.oemSuggestionsService.list(userId, oemCode) };
|
||||
}
|
||||
}
|
||||
13
apps/api/src/oem-suggestions/oem-suggestions.dto.ts
Normal file
13
apps/api/src/oem-suggestions/oem-suggestions.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createOemSuggestionSchema = z.object({
|
||||
oemCode: z.string().trim().min(2, "OEM kodu geçersiz").max(100, "OEM kodu çok uzun"),
|
||||
brand: z.string().trim().min(2, "Marka en az 2 karakter olmalı").max(80, "Marka çok uzun"),
|
||||
suggestedCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, "Parça kodu en az 2 karakter olmalı")
|
||||
.max(100, "Parça kodu çok uzun")
|
||||
.refine((value) => /[a-zA-Z0-9]{2,}/.test(value), "Parça kodu geçersiz"),
|
||||
});
|
||||
export type CreateOemSuggestionInput = z.infer<typeof createOemSuggestionSchema>;
|
||||
45
apps/api/src/oem-suggestions/oem-suggestions.logic.spec.ts
Normal file
45
apps/api/src/oem-suggestions/oem-suggestions.logic.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { aggregateSuggestions, normalizeSuggestedCode } from "./oem-suggestions.logic";
|
||||
|
||||
describe("normalizeSuggestedCode", () => {
|
||||
it("boşluk/tire söker, büyütür", () => {
|
||||
expect(normalizeSuggestedCode("1j0 973-702")).toBe("1J0973702");
|
||||
expect(normalizeSuggestedCode("8V0 615 423 E")).toBe("8V0615423E");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateSuggestions", () => {
|
||||
const row = (userId: string, brand: string, code: string) => ({
|
||||
userId,
|
||||
brand,
|
||||
suggestedCode: code,
|
||||
suggestedCodeNorm: normalizeSuggestedCode(code),
|
||||
});
|
||||
|
||||
it("aynı marka+normalize kodu tek satırda toplar, çok önerilen üste çıkar", () => {
|
||||
const rows = [
|
||||
row("u1", "Bosch", "0 986 478 123"),
|
||||
row("u2", "bosch", "0986478123"),
|
||||
row("u3", "TRW", "DF4823"),
|
||||
];
|
||||
const out = aggregateSuggestions(rows, "u3");
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0]).toMatchObject({ brand: "Bosch", code: "0 986 478 123", count: 2, mine: false });
|
||||
expect(out[1]).toMatchObject({ brand: "TRW", count: 1, mine: true });
|
||||
});
|
||||
|
||||
it("görünen yazım ilk gönderenin yazımıdır", () => {
|
||||
const out = aggregateSuggestions(
|
||||
[row("u1", "VALEO", "PHC123"), row("u2", "Valeo", "phc-123")],
|
||||
"x",
|
||||
);
|
||||
expect(out[0].brand).toBe("VALEO");
|
||||
expect(out[0].code).toBe("PHC123");
|
||||
expect(out[0].count).toBe(2);
|
||||
});
|
||||
|
||||
it("farklı marka aynı kod ayrı satırdır", () => {
|
||||
const out = aggregateSuggestions([row("u1", "Bosch", "X1"), row("u2", "TRW", "X1")], "u1");
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
45
apps/api/src/oem-suggestions/oem-suggestions.logic.ts
Normal file
45
apps/api/src/oem-suggestions/oem-suggestions.logic.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Kod normalizasyonu: gruplama ve kullanıcı-başına dedup anahtarı. Görselde
|
||||
// kullanıcının yazdığı hâl korunur ("1J0 973 702" ↔ "1j0973702" aynı öneri).
|
||||
export function normalizeSuggestedCode(code: string): string {
|
||||
return code.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
}
|
||||
|
||||
export interface SuggestionRow {
|
||||
userId: string;
|
||||
brand: string;
|
||||
suggestedCode: string;
|
||||
suggestedCodeNorm: string;
|
||||
}
|
||||
|
||||
export interface AggregatedSuggestion {
|
||||
brand: string;
|
||||
code: string;
|
||||
count: number;
|
||||
mine: boolean;
|
||||
}
|
||||
|
||||
// Aynı (marka, normalize kod) önerisini tek satırda toplar: en çok önerilen
|
||||
// üstte, eşitlikte ilk gönderilen önde (rows createdAt sıralı gelir). Görünen
|
||||
// marka/kod ilk gönderenin yazımıdır.
|
||||
export function aggregateSuggestions(
|
||||
rows: SuggestionRow[],
|
||||
userId: string,
|
||||
): AggregatedSuggestion[] {
|
||||
const groups = new Map<string, AggregatedSuggestion>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.brand.trim().toLocaleLowerCase("tr-TR")}|${row.suggestedCodeNorm}`;
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
existing.mine ||= row.userId === userId;
|
||||
} else {
|
||||
groups.set(key, {
|
||||
brand: row.brand.trim(),
|
||||
code: row.suggestedCode.trim(),
|
||||
count: 1,
|
||||
mine: row.userId === userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => b.count - a.count);
|
||||
}
|
||||
10
apps/api/src/oem-suggestions/oem-suggestions.module.ts
Normal file
10
apps/api/src/oem-suggestions/oem-suggestions.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { OemSuggestionsController } from "./oem-suggestions.controller";
|
||||
import { OemSuggestionsService } from "./oem-suggestions.service";
|
||||
|
||||
@Module({
|
||||
controllers: [OemSuggestionsController],
|
||||
providers: [OemSuggestionsService],
|
||||
exports: [OemSuggestionsService],
|
||||
})
|
||||
export class OemSuggestionsModule {}
|
||||
60
apps/api/src/oem-suggestions/oem-suggestions.service.ts
Normal file
60
apps/api/src/oem-suggestions/oem-suggestions.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { oemSuggestions } from "../database/schema/core";
|
||||
import {
|
||||
type AggregatedSuggestion,
|
||||
aggregateSuggestions,
|
||||
normalizeSuggestedCode,
|
||||
} from "./oem-suggestions.logic";
|
||||
|
||||
export interface CreateSuggestionResult {
|
||||
created: boolean; // false = bu kullanıcı aynı öneriyi zaten göndermiş
|
||||
suggestions: AggregatedSuggestion[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OemSuggestionsService {
|
||||
constructor(@Inject(DATABASE) private db: Database) {}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
input: { oemCode: string; brand: string; suggestedCode: string },
|
||||
): Promise<CreateSuggestionResult> {
|
||||
const oemCode = input.oemCode.trim();
|
||||
// Aynı kullanıcının aynı (kod, normalize öneri) tekrarı sessizce yutulur —
|
||||
// sayaç şişirme yok; farklı kullanıcılar aynı öneriyi destekleyerek büyütür.
|
||||
const inserted = await this.db
|
||||
.insert(oemSuggestions)
|
||||
.values({
|
||||
userId,
|
||||
oemCode,
|
||||
brand: input.brand.trim(),
|
||||
suggestedCode: input.suggestedCode.trim(),
|
||||
suggestedCodeNorm: normalizeSuggestedCode(input.suggestedCode),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: oemSuggestions.id });
|
||||
|
||||
return {
|
||||
created: inserted.length > 0,
|
||||
suggestions: await this.list(userId, oemCode),
|
||||
};
|
||||
}
|
||||
|
||||
async list(userId: string, oemCode: string): Promise<AggregatedSuggestion[]> {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
userId: oemSuggestions.userId,
|
||||
brand: oemSuggestions.brand,
|
||||
suggestedCode: oemSuggestions.suggestedCode,
|
||||
suggestedCodeNorm: oemSuggestions.suggestedCodeNorm,
|
||||
})
|
||||
.from(oemSuggestions)
|
||||
.where(and(eq(oemSuggestions.oemCode, oemCode.trim()), eq(oemSuggestions.status, "active")))
|
||||
.orderBy(asc(oemSuggestions.createdAt))
|
||||
.limit(500);
|
||||
|
||||
return aggregateSuggestions(rows, userId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user