Merge remote-tracking branch 'origin/dev'
This commit is contained in:
18
apps/api/drizzle/0017_oem_expert_rewards.sql
Normal file
18
apps/api/drizzle/0017_oem_expert_rewards.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Monthly Parça Uzmanları prizes: one row per (TR-month, rank). Written by the
|
||||
-- expert-rewards cron (1st of month 00:00 Europe/Istanbul) when it closes the
|
||||
-- finished season and grants the top 3 voters a subscription extension
|
||||
-- (30/15/7 days). The unique index is the run-once guard — a re-run for the
|
||||
-- same period inserts nothing, so days are never granted twice.
|
||||
CREATE TABLE "oem_expert_rewards" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"period_start" timestamp with time zone NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"rank" integer NOT NULL,
|
||||
"points" integer NOT NULL,
|
||||
"reward_days" integer NOT NULL,
|
||||
"granted_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "oem_expert_rewards" ADD CONSTRAINT "oem_expert_rewards_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_expert_rewards_period_rank_idx" ON "oem_expert_rewards" USING btree ("period_start","rank");--> statement-breakpoint
|
||||
CREATE INDEX "oem_expert_rewards_user_id_idx" ON "oem_expert_rewards" USING btree ("user_id");
|
||||
@@ -120,6 +120,13 @@
|
||||
"when": 1781308800000,
|
||||
"tag": "0016_oem_suggestions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1781395200000,
|
||||
"tag": "0017_oem_expert_rewards",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -742,3 +742,31 @@ export const oemSuggestions = pgTable(
|
||||
index("oem_suggestions_oem_code_idx").on(table.oemCode),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── OEM Expert Rewards (monthly leaderboard prizes) ──
|
||||
// One row per (TR-month, rank): the expert-rewards cron (1st of month 00:00
|
||||
// Europe/Istanbul) closes the finished season and grants the top 3 voters a
|
||||
// subscription extension (30/15/7 days — EXPERT_REWARD_LADDER). The unique
|
||||
// index doubles as the run-once guard: a second run for the same period
|
||||
// inserts nothing, so days are never granted twice. Granting mirrors the
|
||||
// referral mechanic: extend a live active/trial sub, else bank the days in
|
||||
// users.referral_credit_days (consumed at next trial/activation).
|
||||
export const oemExpertRewards = pgTable(
|
||||
"oem_expert_rewards",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
// TR-ayının başlangıcı (UTC timestamptz) — ödüllendirilen sezon.
|
||||
periodStart: timestamp("period_start", { withTimezone: true }).notNull(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
rank: integer("rank").notNull(),
|
||||
points: integer("points").notNull(),
|
||||
rewardDays: integer("reward_days").notNull(),
|
||||
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("oem_expert_rewards_period_rank_idx").on(table.periodStart, table.rank),
|
||||
index("oem_expert_rewards_user_id_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -27,4 +27,5 @@ export const QUEUE_NAMES = {
|
||||
CATALOG_PREFETCH: "catalog-prefetch",
|
||||
TRANSLATION: "translation",
|
||||
LIFECYCLE_EMAIL: "lifecycle-email",
|
||||
EXPERT_REWARDS: "expert-rewards",
|
||||
} as const;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CatalogPrefetchQueueProvider,
|
||||
} from "./queues/catalog-prefetch.queue";
|
||||
import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue";
|
||||
import { EXPERT_REWARDS_QUEUE, ExpertRewardsQueueProvider } from "./queues/expert-rewards.queue";
|
||||
import { LIFECYCLE_EMAIL_QUEUE, LifecycleEmailQueueProvider } from "./queues/lifecycle-email.queue";
|
||||
import { QUERY_CLEANUP_QUEUE, QueryCleanupQueueProvider } from "./queues/query-cleanup.queue";
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
QueryCleanupQueueProvider,
|
||||
CatalogPrefetchQueueProvider,
|
||||
LifecycleEmailQueueProvider,
|
||||
ExpertRewardsQueueProvider,
|
||||
PrefetchWorkerService,
|
||||
],
|
||||
exports: [
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
QUERY_CLEANUP_QUEUE,
|
||||
CATALOG_PREFETCH_QUEUE,
|
||||
LIFECYCLE_EMAIL_QUEUE,
|
||||
EXPERT_REWARDS_QUEUE,
|
||||
],
|
||||
})
|
||||
export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
||||
@@ -39,6 +42,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
||||
@Inject(QUERY_CLEANUP_QUEUE) private queryCleanupQueue: Queue,
|
||||
@Inject(CATALOG_PREFETCH_QUEUE) private catalogPrefetchQueue: Queue,
|
||||
@Inject(LIFECYCLE_EMAIL_QUEUE) private lifecycleEmailQueue: Queue,
|
||||
@Inject(EXPERT_REWARDS_QUEUE) private expertRewardsQueue: Queue,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
@@ -124,6 +128,24 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
||||
await this.catalogPrefetchQueue.removeJobScheduler("catalog-backfill-hourly").catch(() => {});
|
||||
console.log("[jobs] Skipped catalog-backfill cron (not prod host)");
|
||||
}
|
||||
|
||||
// Parça Uzmanları sezon kapanışı: her ayın 1'i 00:00 Türkiye saati —
|
||||
// biten ayın ilk 3 oylayıcısına üyelik uzatması (30/15/7 gün). Ödül
|
||||
// yalnız DB'ye yazar (dış yan etki yok), o yüzden dev'de de çalışır;
|
||||
// (period, rank) unique index'i çift vermeyi zaten engeller.
|
||||
await this.expertRewardsQueue.upsertJobScheduler(
|
||||
"expert-rewards-monthly",
|
||||
{ pattern: "0 0 1 * *", tz: "Europe/Istanbul" },
|
||||
{
|
||||
name: "expert-rewards-grant",
|
||||
data: {},
|
||||
opts: {
|
||||
removeOnComplete: { count: 24 },
|
||||
removeOnFail: { count: 50 },
|
||||
},
|
||||
},
|
||||
);
|
||||
console.log("[jobs] Registered expert-rewards cron: 0 0 1 * * (Europe/Istanbul)");
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
@@ -132,6 +154,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
||||
this.queryCleanupQueue.close(),
|
||||
this.catalogPrefetchQueue.close(),
|
||||
this.lifecycleEmailQueue.close(),
|
||||
this.expertRewardsQueue.close(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
114
apps/api/src/jobs/processors/expert-rewards.processor.ts
Normal file
114
apps/api/src/jobs/processors/expert-rewards.processor.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Job } from "bullmq";
|
||||
import { and, asc, desc, eq, gte, lt, or, sql } from "drizzle-orm";
|
||||
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import {
|
||||
oemExpertRewards,
|
||||
oemVotePoints,
|
||||
userSubscriptions,
|
||||
users,
|
||||
} from "../../database/schema/core";
|
||||
import { EXPERT_REWARD_LADDER, previousTrMonthWindow } from "../../oem-votes/expert-period";
|
||||
|
||||
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
||||
|
||||
// Parça Uzmanları sezon kapanışı: her ayın 1'i 00:00 TR'de biten ayın ilk 3
|
||||
// oylayıcısına üyelik uzatması (30/15/7 gün). oem_expert_rewards'taki
|
||||
// (period, rank) unique index run-once garantisidir — yeniden çalıştırma
|
||||
// (retry, elle tetik) hiçbir şeyi ikinci kez vermez.
|
||||
export async function processExpertRewards(
|
||||
job: Job,
|
||||
db: Database,
|
||||
): Promise<{ period: string; granted: number; skipped: boolean }> {
|
||||
const { start, end } = previousTrMonthWindow(new Date());
|
||||
const periodLabel = start.toISOString();
|
||||
console.log(`[expert-rewards] Processing job ${job.id} for period ${periodLabel}`);
|
||||
|
||||
const [alreadyGranted] = await db
|
||||
.select({ id: oemExpertRewards.id })
|
||||
.from(oemExpertRewards)
|
||||
.where(eq(oemExpertRewards.periodStart, start))
|
||||
.limit(1);
|
||||
if (alreadyGranted) {
|
||||
console.log(`[expert-rewards] Period ${periodLabel} already granted — skipping`);
|
||||
return { period: periodLabel, granted: 0, skipped: true };
|
||||
}
|
||||
|
||||
// Biten sezonun ilk 3'ü — liderlik tablosuyla aynı sıralama: puan desc,
|
||||
// eşitlikte puana daha erken ulaşan önde.
|
||||
const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`;
|
||||
const top = await db
|
||||
.select({ userId: oemVotePoints.userId, points: totalPoints })
|
||||
.from(oemVotePoints)
|
||||
.where(and(gte(oemVotePoints.createdAt, start), lt(oemVotePoints.createdAt, end)))
|
||||
.groupBy(oemVotePoints.userId)
|
||||
.orderBy(desc(totalPoints), asc(sql`min(${oemVotePoints.createdAt})`))
|
||||
.limit(EXPERT_REWARD_LADDER.length);
|
||||
|
||||
if (top.length === 0) {
|
||||
console.log(`[expert-rewards] No votes in period ${periodLabel} — nothing to grant`);
|
||||
return { period: periodLabel, granted: 0, skipped: false };
|
||||
}
|
||||
|
||||
let granted = 0;
|
||||
for (let i = 0; i < top.length; i++) {
|
||||
const winner = top[i];
|
||||
const { rank, days } = EXPERT_REWARD_LADDER[i];
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const inserted = await tx
|
||||
.insert(oemExpertRewards)
|
||||
.values({
|
||||
periodStart: start,
|
||||
userId: winner.userId,
|
||||
rank,
|
||||
points: winner.points,
|
||||
rewardDays: days,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: oemExpertRewards.id });
|
||||
// Yarış/yeniden-deneme: kayıt zaten varsa gün de verilmiş demektir.
|
||||
if (inserted.length === 0) return;
|
||||
|
||||
// Referral ödül mekaniğinin birebir kopyası (worker Nest DI'sız çalıştığı
|
||||
// için ReferralsService.grantRewardDays buradan çağrılamıyor): canlı
|
||||
// active/trial aboneliği uzat, yoksa günleri krediye banka et — kredi bir
|
||||
// sonraki trial/aktivasyonda otomatik tüketilir.
|
||||
const [sub] = await tx
|
||||
.select({ id: userSubscriptions.id, endDate: userSubscriptions.endDate })
|
||||
.from(userSubscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(userSubscriptions.userId, winner.userId),
|
||||
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(userSubscriptions.endDate))
|
||||
.limit(1);
|
||||
|
||||
if (sub?.endDate) {
|
||||
const newEnd = new Date(sub.endDate);
|
||||
newEnd.setDate(newEnd.getDate() + days);
|
||||
await tx
|
||||
.update(userSubscriptions)
|
||||
.set({ endDate: newEnd, updatedAt: new Date() })
|
||||
.where(eq(userSubscriptions.id, sub.id));
|
||||
} else {
|
||||
await tx
|
||||
.update(users)
|
||||
.set({
|
||||
referralCreditDays: sql`${users.referralCreditDays} + ${days}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, winner.userId));
|
||||
}
|
||||
|
||||
granted++;
|
||||
console.log(
|
||||
`[expert-rewards] rank=${rank} user=${winner.userId} points=${winner.points} +${days}d`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[expert-rewards] Period ${periodLabel}: granted ${granted} reward(s)`);
|
||||
return { period: periodLabel, granted, skipped: false };
|
||||
}
|
||||
@@ -67,17 +67,11 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
|
||||
.innerJoin(users, eq(userSubscriptions.userId, users.id))
|
||||
.leftJoin(
|
||||
emailPreferences,
|
||||
and(
|
||||
eq(emailPreferences.userId, users.id),
|
||||
eq(emailPreferences.workflow, "trial-ending"),
|
||||
),
|
||||
and(eq(emailPreferences.userId, users.id), eq(emailPreferences.workflow, "trial-ending")),
|
||||
)
|
||||
.leftJoin(
|
||||
lifecycleEmailSent,
|
||||
and(
|
||||
eq(lifecycleEmailSent.userId, users.id),
|
||||
eq(lifecycleEmailSent.workflow, "trial-ending"),
|
||||
),
|
||||
and(eq(lifecycleEmailSent.userId, users.id), eq(lifecycleEmailSent.workflow, "trial-ending")),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
@@ -96,11 +90,7 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
|
||||
{ subscriberId: r.userId, email: r.email, firstName: firstNameOf(r.name) },
|
||||
{
|
||||
daysLeft: 3,
|
||||
ctaUrl: buildTrackedUrl(
|
||||
"trial-ending",
|
||||
r.email,
|
||||
webUrl("/dashboard/subscription"),
|
||||
),
|
||||
ctaUrl: buildTrackedUrl("trial-ending", r.email, webUrl("/dashboard/subscription")),
|
||||
...(trackPixel ? { trackPixel } : {}),
|
||||
},
|
||||
);
|
||||
@@ -134,17 +124,11 @@ async function sendWinBack(db: Database, now: Date): Promise<number> {
|
||||
.innerJoin(users, eq(userSubscriptions.userId, users.id))
|
||||
.leftJoin(
|
||||
emailPreferences,
|
||||
and(
|
||||
eq(emailPreferences.userId, users.id),
|
||||
eq(emailPreferences.workflow, "win-back"),
|
||||
),
|
||||
and(eq(emailPreferences.userId, users.id), eq(emailPreferences.workflow, "win-back")),
|
||||
)
|
||||
.leftJoin(
|
||||
lifecycleEmailSent,
|
||||
and(
|
||||
eq(lifecycleEmailSent.userId, users.id),
|
||||
eq(lifecycleEmailSent.workflow, "win-back"),
|
||||
),
|
||||
and(eq(lifecycleEmailSent.userId, users.id), eq(lifecycleEmailSent.workflow, "win-back")),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
|
||||
25
apps/api/src/jobs/queues/expert-rewards.queue.ts
Normal file
25
apps/api/src/jobs/queues/expert-rewards.queue.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Provider } from "@nestjs/common";
|
||||
import { Queue } from "bullmq";
|
||||
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
||||
|
||||
export const EXPERT_REWARDS_QUEUE = "EXPERT_REWARDS_QUEUE";
|
||||
|
||||
export const ExpertRewardsQueueProvider: Provider = {
|
||||
provide: EXPERT_REWARDS_QUEUE,
|
||||
useFactory: () => {
|
||||
const telemetry = getBullTelemetry();
|
||||
return new Queue(QUEUE_NAMES.EXPERT_REWARDS, {
|
||||
connection: getBullConnection(),
|
||||
...(telemetry ? { telemetry } : {}),
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 10000,
|
||||
},
|
||||
removeOnComplete: { count: 100 },
|
||||
removeOnFail: { count: 200 },
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
37
apps/api/src/oem-votes/expert-period.spec.ts
Normal file
37
apps/api/src/oem-votes/expert-period.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EXPERT_REWARD_LADDER, previousTrMonthWindow, trMonthStart } from "./expert-period";
|
||||
|
||||
describe("trMonthStart", () => {
|
||||
it("ay ortasında TR-ayının başını UTC olarak verir (TR 00:00 = UTC 21:00 önceki gün)", () => {
|
||||
const now = new Date("2026-06-15T10:00:00Z");
|
||||
expect(trMonthStart(now).toISOString()).toBe("2026-05-31T21:00:00.000Z");
|
||||
});
|
||||
|
||||
it("UTC gece TR'de yeni aya geçmişse TR gününü baz alır", () => {
|
||||
// UTC 30 Haziran 22:30 = TR 1 Temmuz 01:30 → içinde bulunulan TR-ayı Temmuz
|
||||
const now = new Date("2026-06-30T22:30:00Z");
|
||||
expect(trMonthStart(now).toISOString()).toBe("2026-06-30T21:00:00.000Z");
|
||||
expect(trMonthStart(now, 1).toISOString()).toBe("2026-05-31T21:00:00.000Z");
|
||||
});
|
||||
|
||||
it("yıl devrini doğru çözer (TR ocak → geriye aralık)", () => {
|
||||
const now = new Date("2026-01-10T12:00:00Z");
|
||||
expect(trMonthStart(now, 1).toISOString()).toBe("2025-11-30T21:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("previousTrMonthWindow", () => {
|
||||
it("cron anında (TR 1'i 00:00) biten ayı kapsar", () => {
|
||||
// TR 1 Temmuz 00:00 = UTC 30 Haziran 21:00
|
||||
const cronMoment = new Date("2026-06-30T21:00:00Z");
|
||||
const { start, end } = previousTrMonthWindow(cronMoment);
|
||||
expect(start.toISOString()).toBe("2026-05-31T21:00:00.000Z"); // TR 1 Haziran 00:00
|
||||
expect(end.toISOString()).toBe("2026-06-30T21:00:00.000Z"); // TR 1 Temmuz 00:00
|
||||
});
|
||||
});
|
||||
|
||||
describe("EXPERT_REWARD_LADDER", () => {
|
||||
it("1. → 30 gün, 2. → 15 gün, 3. → 7 gün", () => {
|
||||
expect(EXPERT_REWARD_LADDER.map((r) => r.days)).toEqual([30, 15, 7]);
|
||||
});
|
||||
});
|
||||
23
apps/api/src/oem-votes/expert-period.ts
Normal file
23
apps/api/src/oem-votes/expert-period.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Parça Uzmanları aylık sezon takvimi. Sınırlar Türkiye saatine göredir
|
||||
// (Europe/Istanbul, 2016'dan beri sabit UTC+3 — DST yok); sıralama her ayın
|
||||
// 1'i 00:00 TR'de sıfırlanır, biten ayın ilk 3'üne üyelik uzatması verilir.
|
||||
const TR_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||||
|
||||
// Ödül merdiveni: 1. → 1 ay (30 gün), 2. → 15 gün, 3. → 7 gün üyelik.
|
||||
export const EXPERT_REWARD_LADDER = [
|
||||
{ rank: 1, days: 30 },
|
||||
{ rank: 2, days: 15 },
|
||||
{ rank: 3, days: 7 },
|
||||
] as const;
|
||||
|
||||
// `monthsBack` ay geriden TR-ayının başlangıcını UTC Date olarak döndürür
|
||||
// (0 = içinde bulunulan ay). Date.UTC ay taşmalarını (ocak-1 → aralık) çözer.
|
||||
export function trMonthStart(now: Date, monthsBack = 0): Date {
|
||||
const tr = new Date(now.getTime() + TR_OFFSET_MS);
|
||||
return new Date(Date.UTC(tr.getUTCFullYear(), tr.getUTCMonth() - monthsBack, 1) - TR_OFFSET_MS);
|
||||
}
|
||||
|
||||
// Biten sezonun penceresi: [önceki TR-ay başı, bu TR-ay başı).
|
||||
export function previousTrMonthWindow(now: Date): { start: Date; end: Date } {
|
||||
return { start: trMonthStart(now, 1), end: trMonthStart(now, 0) };
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { and, count, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { oemVotePoints, oemVotes, users } from "../database/schema/core";
|
||||
import { trMonthStart } from "./expert-period";
|
||||
import {
|
||||
type OemVoteChoice,
|
||||
type VoteCounts,
|
||||
@@ -149,12 +150,18 @@ export class OemVotesService {
|
||||
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.
|
||||
// Parça Uzmanları: içinde bulunulan TR-ayının (sezonun) sıralaması — her
|
||||
// ayın 1'i 00:00 TR'de sıfırlanır, biten ayın ilk 3'üne expert-rewards
|
||||
// cron'u üyelik uzatması verir. Puana göre herkes, adlar maskeli
|
||||
// ("S*** Y***"); yanıt userId sızdırmaz, istek sahibi kendi satırını isMe
|
||||
// ile bulur. Sıralama ödül job'ıyla birebir aynı: puan desc, eşitlikte
|
||||
// puana daha erken ulaşan önde.
|
||||
async leaderboard(userId: string): Promise<{
|
||||
entries: LeaderboardEntry[];
|
||||
me: { rank: number | null; points: number; votes: number };
|
||||
periodStart: string;
|
||||
}> {
|
||||
const periodStart = trMonthStart(new Date());
|
||||
const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`;
|
||||
const rows = await this.db
|
||||
.select({
|
||||
@@ -165,6 +172,7 @@ export class OemVotesService {
|
||||
})
|
||||
.from(oemVotePoints)
|
||||
.innerJoin(users, eq(users.id, oemVotePoints.userId))
|
||||
.where(gte(oemVotePoints.createdAt, periodStart))
|
||||
.groupBy(oemVotePoints.userId, users.name)
|
||||
.orderBy(desc(totalPoints), sql`min(${oemVotePoints.createdAt}) asc`)
|
||||
.limit(500);
|
||||
@@ -180,6 +188,7 @@ export class OemVotesService {
|
||||
return {
|
||||
entries,
|
||||
me: { rank: my?.rank ?? null, points: my?.points ?? 0, votes: my?.votes ?? 0 },
|
||||
periodStart: periodStart.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import OpenAI from "openai";
|
||||
import postgres from "postgres";
|
||||
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "./jobs/bull.config";
|
||||
import { processEmexScrape } from "./jobs/processors/emex-scrape.processor";
|
||||
import { processExpertRewards } from "./jobs/processors/expert-rewards.processor";
|
||||
import { processLifecycleEmails } from "./jobs/processors/lifecycle-email.processor";
|
||||
import { processQueryCleanup } from "./jobs/processors/query-cleanup.processor";
|
||||
import { processSubscriptionExpiry } from "./jobs/processors/subscription-expiry.processor";
|
||||
@@ -159,6 +160,32 @@ lifecycleEmailWorker.on("failed", (job, err) => {
|
||||
|
||||
workers.push(lifecycleEmailWorker);
|
||||
|
||||
// Expert Rewards Worker (monthly Parça Uzmanları top-3 subscription prizes)
|
||||
const expertRewardsWorker = new Worker(
|
||||
QUEUE_NAMES.EXPERT_REWARDS,
|
||||
async (job) => {
|
||||
return processExpertRewards(job, db);
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 1,
|
||||
...(telemetry ? { telemetry } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
expertRewardsWorker.on("completed", (job) => {
|
||||
console.log(`[worker] expert-rewards job ${job.id} completed`);
|
||||
});
|
||||
|
||||
expertRewardsWorker.on("failed", (job, err) => {
|
||||
console.error(`[worker] expert-rewards job ${job?.id} failed: ${err.message}`);
|
||||
Sentry.captureException(err, {
|
||||
tags: { queue: QUEUE_NAMES.EXPERT_REWARDS, jobId: job?.id },
|
||||
});
|
||||
});
|
||||
|
||||
workers.push(expertRewardsWorker);
|
||||
|
||||
// Translation Worker (async LLM translation for new EMEX/PCAT terms)
|
||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (openrouterApiKey) {
|
||||
|
||||
@@ -44,21 +44,27 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("OemVoteCard", () => {
|
||||
it("loads the summary for the code and shows the counts", async () => {
|
||||
it("loads the summary, marks my vote and hides the tallies", async () => {
|
||||
vi.mocked(api.post).mockResolvedValue({
|
||||
votes: { "8V0615423E": { compatible: 4, incompatible: 1, myVote: "compatible" } },
|
||||
});
|
||||
|
||||
render(<OemVoteCard oemCode="8V0615423E" />);
|
||||
render(
|
||||
<OemVoteCard oemCode="8V0615423E" vehicleLabel="Audi A4 (2018)" partName="Fren balatası" />,
|
||||
);
|
||||
|
||||
// bağlam satırı: neyle neyin uyumlu olduğu tek alanda
|
||||
expect(screen.getByText("Audi A4 (2018) · Fren balatası")).toBeInTheDocument();
|
||||
|
||||
const upButton = screen.getByRole("button", { name: /Uyumlu/ });
|
||||
const downButton = screen.getByRole("button", { name: /Uyumsuz/ });
|
||||
await waitFor(() => {
|
||||
expect(api.post).toHaveBeenCalledWith("/oem-votes/lookup", { codes: ["8V0615423E"] });
|
||||
expect(upButton.textContent).toContain("4");
|
||||
expect(downButton.textContent).toContain("1");
|
||||
expect(upButton).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
expect(upButton).toHaveAttribute("aria-pressed", "true");
|
||||
// sayaçlar butonlarda görünmez (çoğunluk oyu yönlendirmesin)
|
||||
expect(upButton.textContent).not.toContain("4");
|
||||
expect(downButton.textContent).not.toContain("1");
|
||||
});
|
||||
|
||||
it("casts a vote, updates the counts and reports the points", async () => {
|
||||
@@ -84,17 +90,16 @@ describe("OemVoteCard", () => {
|
||||
fireEvent.click(downButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postMock).toHaveBeenCalledWith("/oem-votes", {
|
||||
oemCode: "X1",
|
||||
vote: "incompatible",
|
||||
});
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
"/oem-votes",
|
||||
expect.objectContaining({ oemCode: "X1", vote: "incompatible" }),
|
||||
);
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith("+3 puan kazandınız!", expect.anything());
|
||||
expect(captureMock).toHaveBeenCalledWith(
|
||||
"oem_vote_cast",
|
||||
expect.objectContaining({ oem_code: "X1", vote: "incompatible", surface: "oem_detail" }),
|
||||
);
|
||||
});
|
||||
expect(downButton.textContent).toContain("1");
|
||||
expect(downButton).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { capture } from "@/lib/posthog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { cn } from "@sase/ui";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { Car, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type OemVoteChoice = "compatible" | "incompatible";
|
||||
@@ -23,15 +23,34 @@ export interface CastOemVoteResult {
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
interface OemVoteCardProps {
|
||||
oemCode: string;
|
||||
// Katalogdan gelişte taşınan bağlam: "neyle neyin uyumlu olduğu" tek alanda
|
||||
// görünsün diye butonların üstünde araç + parça adı gösterilir. Doğrudan
|
||||
// ziyarette (bağlam yok) genel soru metnine düşülür.
|
||||
vehicleLabel?: string;
|
||||
partName?: string;
|
||||
vehicleId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EMPTY_SUMMARY: OemVoteSummary = { compatible: 0, incompatible: 0, myVote: null };
|
||||
|
||||
// OEM detay sayfasındaki topluluk oyu kartı: uyumlu/uyumsuz + sayaçlar.
|
||||
// Kendi durumunu yönetir; oy puanları toast ile bildirilir (oy +1, çoğunluk
|
||||
// +2, ilk oy 3 — kural API'deki computeVoteAward'da).
|
||||
export function OemVoteCard({ oemCode }: { oemCode: string }) {
|
||||
// OEM detay başlığındaki topluluk oyu kartı: uyumlu/uyumsuz + sayaçlar.
|
||||
// Arkaplanı sayfayla aynı (dolgu yok, yalnız çerçeve). Puan kuralı yalnız
|
||||
// Parça Uzmanları sayfasında yazılıdır; burada toast geri bildirimi yeter.
|
||||
export function OemVoteCard({
|
||||
oemCode,
|
||||
vehicleLabel,
|
||||
partName,
|
||||
vehicleId,
|
||||
className,
|
||||
}: OemVoteCardProps) {
|
||||
const [summary, setSummary] = useState<OemVoteSummary | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const context = [vehicleLabel, partName].filter(Boolean).join(" · ");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSummary(null);
|
||||
@@ -51,7 +70,11 @@ export function OemVoteCard({ oemCode }: { oemCode: string }) {
|
||||
const cast = async (vote: OemVoteChoice) => {
|
||||
setPending(true);
|
||||
try {
|
||||
const result = await api.post<CastOemVoteResult>("/oem-votes", { oemCode, vote });
|
||||
const result = await api.post<CastOemVoteResult>("/oem-votes", {
|
||||
oemCode,
|
||||
vote,
|
||||
vehicleId,
|
||||
});
|
||||
setSummary({ ...result.counts, myVote: result.myVote });
|
||||
if (result.isNew) {
|
||||
const isFirstVote = result.counts.compatible + result.counts.incompatible === 1;
|
||||
@@ -71,6 +94,7 @@ export function OemVoteCard({ oemCode }: { oemCode: string }) {
|
||||
points_awarded: result.pointsAwarded,
|
||||
correct: result.correct,
|
||||
is_new: result.isNew,
|
||||
vehicle_id: vehicleId,
|
||||
surface: "oem_detail",
|
||||
});
|
||||
} catch {
|
||||
@@ -80,56 +104,60 @@ export function OemVoteCard({ oemCode }: { oemCode: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Sayaçlar bilinçli olarak gösterilmiyor (çoğunluğu ele vermek oyu yönlendirir);
|
||||
// özet yine de çekilir — kullanıcının kendi oyu butonda işaretli kalır.
|
||||
const options = [
|
||||
{
|
||||
vote: "compatible" as const,
|
||||
icon: ThumbsUp,
|
||||
label: "Uyumlu",
|
||||
count: summary?.compatible ?? 0,
|
||||
activeClass: "border-green-500/50 bg-green-500/10 text-green-500",
|
||||
},
|
||||
{
|
||||
vote: "incompatible" as const,
|
||||
icon: ThumbsDown,
|
||||
label: "Uyumsuz",
|
||||
count: summary?.incompatible ?? 0,
|
||||
activeClass: "border-red-500/50 bg-red-500/10 text-red-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-background p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold">Topluluk uyumluluk oyu</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Bu OEM kodu sizce doğru mu? Oyunuz diğer parça satıcılarına yol gösterir.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{options.map(({ vote, icon: Icon, label, count, activeClass }) => {
|
||||
const isActive = summary?.myVote === vote;
|
||||
return (
|
||||
<button
|
||||
key={vote}
|
||||
type="button"
|
||||
disabled={pending || summary === null}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => cast(vote)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50",
|
||||
isActive
|
||||
? activeClass
|
||||
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" fill={isActive ? "currentColor" : "none"} />
|
||||
{label}
|
||||
<span className="font-semibold tabular-nums">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<section className={cn("rounded-xl border border-border p-4", className)}>
|
||||
<h2 className="text-sm font-semibold">Topluluk uyumluluk oyu</h2>
|
||||
{context ? (
|
||||
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Car className="size-3.5 shrink-0" />
|
||||
<span className="truncate" title={context}>
|
||||
{context}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Bu OEM kodu sizce doğru mu? Oyunuz diğer parça satıcılarına yol gösterir.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex gap-2">
|
||||
{options.map(({ vote, icon: Icon, label, activeClass }) => {
|
||||
const isActive = summary?.myVote === vote;
|
||||
return (
|
||||
<button
|
||||
key={vote}
|
||||
type="button"
|
||||
disabled={pending || summary === null}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => cast(vote)}
|
||||
className={cn(
|
||||
"inline-flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50",
|
||||
isActive
|
||||
? activeClass
|
||||
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" fill={isActive ? "currentColor" : "none"} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Her oy puan kazandırır ·{" "}
|
||||
|
||||
@@ -157,7 +157,9 @@ describe("PartsPanel", () => {
|
||||
render(<PartsPanel parts={[buildPart()]} vehicleId="v1" categoryId="c1" />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "OEM-1" });
|
||||
expect(link).toHaveAttribute("href", "/dashboard/oem/OEM-1");
|
||||
// href bağlam taşır: parça adı + vehicleId (oy kartındaki "araç · parça" satırı)
|
||||
expect(link.getAttribute("href")).toContain("/dashboard/oem/OEM-1");
|
||||
expect(link.getAttribute("href")).toContain("vid=v1");
|
||||
// Liste açılışı artık toplu istek atmaz (/p/matched ve oy lookup'ı kalktı);
|
||||
// tek istisna kopyalama anındaki fire-and-forget analytics POST'udur.
|
||||
expect(postMock).not.toHaveBeenCalled();
|
||||
|
||||
@@ -22,6 +22,17 @@ interface PartsPanelProps {
|
||||
|
||||
const SKELETON_ROW_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"] as const;
|
||||
|
||||
// OEM detay linki, oy kartının "araç · parça" bağlam satırı için görüntü
|
||||
// bağlamını search-param olarak taşır (v/p/vid; oem.$code validateSearch).
|
||||
function oemDetailHref(code: string, search: { v?: string; p?: string; vid?: string }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (search.v) qs.set("v", search.v);
|
||||
if (search.p) qs.set("p", search.p);
|
||||
if (search.vid) qs.set("vid", search.vid);
|
||||
const tail = qs.toString();
|
||||
return `/dashboard/oem/${encodeURIComponent(code)}${tail ? `?${tail}` : ""}`;
|
||||
}
|
||||
|
||||
export function PartsPanel({
|
||||
parts,
|
||||
vehicleId,
|
||||
@@ -372,7 +383,11 @@ export function PartsPanel({
|
||||
// SPA reload); real href kept so ctrl/cmd/middle-click
|
||||
// still opens a new tab.
|
||||
<a
|
||||
href={`/dashboard/oem/${encodeURIComponent(part.oemCode)}`}
|
||||
href={oemDetailHref(part.oemCode, {
|
||||
v: vehicleLabel,
|
||||
p: part.name,
|
||||
vid: vehicleId,
|
||||
})}
|
||||
title="Uyumlu parça kodlarını gör"
|
||||
className="underline decoration-dotted underline-offset-2 transition-colors hover:text-foreground hover:decoration-solid"
|
||||
onClick={(e) => {
|
||||
@@ -388,6 +403,7 @@ export function PartsPanel({
|
||||
navigate({
|
||||
to: "/dashboard/oem/$code",
|
||||
params: { code: part.oemCode },
|
||||
search: { v: vehicleLabel, p: part.name, vid: vehicleId },
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
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";
|
||||
@@ -116,11 +117,24 @@ function PartThumb({ src, alt }: { src: string | null; alt: string }) {
|
||||
// ─── Route ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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 } => ({
|
||||
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,
|
||||
}),
|
||||
component: OemDetailPage,
|
||||
});
|
||||
|
||||
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],
|
||||
@@ -171,33 +185,42 @@ function OemDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* ─── Header ─────────────────────────────────────────────────────── */}
|
||||
<header className="flex items-start gap-4">
|
||||
<Button asChild variant="ghost" size="icon" className="mt-0.5 shrink-0">
|
||||
<Link to="/dashboard/search" aria-label="Geri">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
OEM kodu
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||
<h1 className="font-mono text-2xl font-bold tracking-tight break-all">{code}</h1>
|
||||
<CopyCode
|
||||
code={code}
|
||||
className="rounded-md border border-border px-2 py-1 hover:bg-accent"
|
||||
/>
|
||||
{/* ─── Header: solda OEM kodu, desktop'ta sağ simetriğinde topluluk
|
||||
oyu kartı; mobilde kart kodun altına iner ─────────────────────── */}
|
||||
<header className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-4">
|
||||
<Button asChild variant="ghost" size="icon" className="mt-0.5 shrink-0">
|
||||
<Link to="/dashboard/search" aria-label="Geri">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
OEM kodu
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||
<h1 className="font-mono text-2xl font-bold tracking-tight break-all">{code}</h1>
|
||||
<CopyCode
|
||||
code={code}
|
||||
className="rounded-md border border-border px-2 py-1 hover:bg-accent"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Uyumlu parça kodları ve muadil numaralar
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Uyumlu parça kodları ve muadil numaralar
|
||||
</p>
|
||||
</div>
|
||||
{expertEnabled && (
|
||||
<OemVoteCard
|
||||
oemCode={code}
|
||||
vehicleLabel={vehicleLabel}
|
||||
partName={partName}
|
||||
vehicleId={vehicleId}
|
||||
className="w-full lg:w-96 lg:shrink-0"
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* ─── Topluluk uyumluluk oyu (P eşleşmesinden bağımsız) ───────────── */}
|
||||
<OemVoteCard oemCode={code} />
|
||||
|
||||
{/* ─── Loading ────────────────────────────────────────────────────── */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
@@ -346,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";
|
||||
@@ -23,6 +24,24 @@ interface LeaderboardEntry {
|
||||
interface LeaderboardResponse {
|
||||
entries: LeaderboardEntry[];
|
||||
me: { rank: number | null; points: number; votes: number };
|
||||
// İçinde bulunulan TR-ayı sezonunun başlangıcı (ISO) — sezon etiketi bundan üretilir.
|
||||
periodStart?: string;
|
||||
}
|
||||
|
||||
// Aylık ödül merdiveni (API'deki EXPERT_REWARD_LADDER ile aynı).
|
||||
const SEASON_PRIZES = [
|
||||
{ key: "r1", medal: "🥇", label: "1 ay üyelik" },
|
||||
{ key: "r2", medal: "🥈", label: "15 gün" },
|
||||
{ key: "r3", medal: "🥉", label: "7 gün" },
|
||||
] as const;
|
||||
|
||||
function seasonLabel(periodStart?: string): string {
|
||||
const date = periodStart ? new Date(periodStart) : new Date();
|
||||
return new Intl.DateTimeFormat("tr-TR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Istanbul",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/dashboard/uzmanlar")({
|
||||
@@ -32,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;
|
||||
|
||||
@@ -69,12 +114,29 @@ function ExpertsPage() {
|
||||
Parça Uzmanları
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
OEM kodlarını oyla, puan topla, sıralamada yüksel — doğru bilgi bütün parça
|
||||
satıcılarının işini hızlandırır.
|
||||
OEM kodlarını oyla, puan topla, sıralamada yüksel — doğru bilgi bütün parça satıcılarının
|
||||
işini hızlandırır.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
{/* Sezon şeridi: ayın ilk 3 uzmanı üyelik kazanır */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl border border-amber-400/30 bg-amber-400/5 px-4 py-3 text-sm">
|
||||
<span className="font-semibold capitalize">{seasonLabel(data?.periodStart)} sezonu</span>
|
||||
<span className="text-muted-foreground">ayın ilk 3 uzmanına üyelik:</span>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{SEASON_PRIZES.map(({ key, medal, label }) => (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border bg-background px-2.5 py-0.5 text-xs font-medium"
|
||||
>
|
||||
<span aria-hidden="true">{medal}</span>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isLoading || accessLoading ? (
|
||||
<div className="space-y-3">
|
||||
{SKELETON_KEYS.map((k) => (
|
||||
<Skeleton key={k} className="h-14 w-full" />
|
||||
@@ -128,7 +190,9 @@ function ExpertsPage() {
|
||||
<p className="pt-2 text-center text-[11px] leading-relaxed text-muted-foreground/70">
|
||||
Oy ver <span className="font-semibold text-muted-foreground">+1</span> · çoğunluğu tuttur{" "}
|
||||
<span className="font-semibold text-muted-foreground">+2 bonus</span> · kodu ilk
|
||||
değerlendiren <span className="font-semibold text-muted-foreground">3 puanı</span> kapar
|
||||
değerlendiren <span className="font-semibold text-muted-foreground">3 puanı</span> kapar ·
|
||||
sıralama her ayın 1'i 00:00'da sıfırlanır, biten ayın ilk 3'üne üyelik uzatması otomatik
|
||||
tanımlanır
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user