feat(gamification): aylık uzman sezonları — ilk 3'e otomatik üyelik uzatması

Parça Uzmanları sıralaması artık aylık sezon: liderlik tablosu içinde
bulunulan TR-ayının (Europe/Istanbul) puanlarını gösterir ve her ayın
1'i 00:00 TR'de kendiliğinden sıfırlanır. expert-rewards cron'u
(BullMQ scheduler, "0 0 1 * *" tz=Europe/Istanbul) aynı anda biten
sezonu kapatır ve ilk 3 oylayıcıya üyelik uzatması verir:
1. → 30 gün, 2. → 15 gün, 3. → 7 gün (EXPERT_REWARD_LADDER).

Ödül mekaniği referral'la birebir: canlı active/trial abonelik endDate
+gün uzar, yoksa günler users.referral_credit_days'e bankalanır (sonraki
trial/aktivasyonda tüketilir). oem_expert_rewards (migration 0017,
period+rank UNIQUE) hem denetim kaydı hem run-once garantisi — retry ya
da elle tetik çift ödül veremez. Sıralama ölçütü job ve leaderboard'da
birebir aynı (puan desc, eşitlikte puana erken ulaşan önde).

Web: uzmanlar sayfasına sezon şeridi ("Haziran 2026 sezonu" + 🥇1 ay ·
🥈15 gün · 🥉7 gün rozetleri); alt küçük-punto kural satırına aylık
sıfırlama + ödül notu eklendi. Leaderboard cevabına periodStart eklendi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 10:48:36 +03:00
parent 55894bec0e
commit fe35bbd826
13 changed files with 360 additions and 27 deletions

View 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");

View File

@@ -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
}
]
}

View File

@@ -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),
],
);

View File

@@ -27,4 +27,5 @@ export const QUEUE_NAMES = {
CATALOG_PREFETCH: "catalog-prefetch",
TRANSLATION: "translation",
LIFECYCLE_EMAIL: "lifecycle-email",
EXPERT_REWARDS: "expert-rewards",
} as const;

View File

@@ -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(),
]);
}
}

View 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 };
}

View File

@@ -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(

View 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 },
},
});
},
};

View 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]);
});
});

View 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) };
}

View File

@@ -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(),
};
}

View File

@@ -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) {